mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-08 11:29:46 -06:00
Compare commits
315
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c58c76914 | ||
|
|
c000e1b3ff | ||
|
|
21920d3d61 | ||
|
|
a2509bdf8b | ||
|
|
0f5a860d74 | ||
|
|
17131fb0e5 | ||
|
|
94a8fb084a | ||
|
|
fca22f6e93 | ||
|
|
300b074894 | ||
|
|
9dd3b51bc6 | ||
|
|
4a93722ea6 | ||
|
|
6338cef450 | ||
|
|
24119e49fe | ||
|
|
cbd0879c44 | ||
|
|
0057fc00c9 | ||
|
|
9ab297ced1 | ||
|
|
2ee261c0e8 | ||
|
|
7cb0088361 | ||
|
|
4bba98fd50 | ||
|
|
640fbf22f6 | ||
|
|
865a7f6a06 | ||
|
|
1458f45214 | ||
|
|
05a5845234 | ||
|
|
762be28e29 | ||
|
|
8e5bcf1575 | ||
|
|
83670dfe3a | ||
|
|
93dab0c420 | ||
|
|
dea064b857 | ||
|
|
9354fdb0c4 | ||
|
|
5f84fadaf5 | ||
|
|
9ba79c3e38 | ||
|
|
6a7cf9cf66 | ||
|
|
c35878948a | ||
|
|
42370639e0 | ||
|
|
6b19e445f4 | ||
|
|
05cffdceb1 | ||
|
|
87cb0f11d2 | ||
|
|
12f69f99bf | ||
|
|
225496e544 | ||
|
|
c125ce4780 | ||
|
|
76002fe0bd | ||
|
|
505cae34c8 | ||
|
|
ee1b5df3db | ||
|
|
cbd765332c | ||
|
|
b9e5e0ff89 | ||
|
|
66bb6ae239 | ||
|
|
bc1625248e | ||
|
|
4fc2c9c3b3 | ||
|
|
546a546943 | ||
|
|
8774fa45fb | ||
|
|
8325dbe50e | ||
|
|
fb17d0d1d5 | ||
|
|
89ec06f041 | ||
|
|
20473a4fae | ||
|
|
1688b43ae2 | ||
|
|
7d3ede854f | ||
|
|
6a8eb4ff42 | ||
|
|
543497acff | ||
|
|
620a91904f | ||
|
|
ff35c6b89d | ||
|
|
21b64df0fe | ||
|
|
292b784f02 | ||
|
|
c402953828 | ||
|
|
0a58f94517 | ||
|
|
6ea9f538a7 | ||
|
|
4e43ad8868 | ||
|
|
e06b2a7ead | ||
|
|
bf6bee500a | ||
|
|
d628abb7b0 | ||
|
|
cb05971659 | ||
|
|
558312cb53 | ||
|
|
1fb191571b | ||
|
|
ede12f9da6 | ||
|
|
642980b724 | ||
|
|
73fad54671 | ||
|
|
23c909e477 | ||
|
|
0955378d8e | ||
|
|
756d5981b1 | ||
|
|
7e828a982c | ||
|
|
919a6b58fe | ||
|
|
e8bdd0efd6 | ||
|
|
3162ac09f3 | ||
|
|
b9595ccdec | ||
|
|
632e4a1bc5 | ||
|
|
e5f6fa8197 | ||
|
|
f77ed98cdb | ||
|
|
22c5dfe0df | ||
|
|
cdd3a59dbc | ||
|
|
bebe7de877 | ||
|
|
4421bc6790 | ||
|
|
017c80572c | ||
|
|
a31178f519 | ||
|
|
184c0ec84d | ||
|
|
065f05dbec | ||
|
|
db05400e67 | ||
|
|
c0ea297615 | ||
|
|
74111e5a93 | ||
|
|
b3a709f314 | ||
|
|
86c5c8dd81 | ||
|
|
014f030a15 | ||
|
|
f077b9ef36 | ||
|
|
40e75c7912 | ||
|
|
d8ba397bb7 | ||
|
|
7e8b94fc39 | ||
|
|
22c06b7a4a | ||
|
|
a305229287 | ||
|
|
1ff10df557 | ||
|
|
472132b158 | ||
|
|
df137b8ee8 | ||
|
|
e12b462b90 | ||
|
|
ed2347782d | ||
|
|
865a789c73 | ||
|
|
166234eff0 | ||
|
|
6c386f79ed | ||
|
|
5ae7ecfab9 | ||
|
|
0081e4f29e | ||
|
|
ebf762a03a | ||
|
|
3bdfa6ed71 | ||
|
|
d906bcbb6f | ||
|
|
d7b53fcc20 | ||
|
|
31156f7949 | ||
|
|
502fac89fa | ||
|
|
56cc52eb39 | ||
|
|
a6b7cf5c2a | ||
|
|
85f09f05d6 | ||
|
|
cb4b7af1e3 | ||
|
|
0670337908 | ||
|
|
2e27cbbf05 | ||
|
|
0a12da6e52 | ||
|
|
5ff51dd142 | ||
|
|
affae49e7c | ||
|
|
89d658406e | ||
|
|
a10d220cea | ||
|
|
b148f06e9c | ||
|
|
b5d888d3cc | ||
|
|
76145982c5 | ||
|
|
1a7fa68376 | ||
|
|
fe062f57a6 | ||
|
|
94dd42ba31 | ||
|
|
72a9c5b47d | ||
|
|
dfb3b1224b | ||
|
|
58e876a4d8 | ||
|
|
d976bbdcba | ||
|
|
ee6b9fdcee | ||
|
|
b3bce6cc81 | ||
|
|
2caf435a38 | ||
|
|
38a7067ca2 | ||
|
|
f4f69beec2 | ||
|
|
92cdc2a975 | ||
|
|
fe6ab7c5c0 | ||
|
|
2bdec6c0a1 | ||
|
|
533c0dadaf | ||
|
|
68e87f0a68 | ||
|
|
2adfd5571f | ||
|
|
d964c1cb66 | ||
|
|
9ce599d978 | ||
|
|
3c72d5f7aa | ||
|
|
6fcb94f7e3 | ||
|
|
e56a722bb4 | ||
|
|
94b4f6a3a2 | ||
|
|
ac27137cd8 | ||
|
|
dea1359c7e | ||
|
|
3c0749e685 | ||
|
|
f9edafa800 | ||
|
|
a774d18350 | ||
|
|
e89ba799e9 | ||
|
|
56f0679fed | ||
|
|
c35ab0f4f4 | ||
|
|
846cc09d67 | ||
|
|
48c52e14af | ||
|
|
f0557ec443 | ||
|
|
17f733a81e | ||
|
|
914a76a9d8 | ||
|
|
e23994b7cb | ||
|
|
5acb4af789 | ||
|
|
c121fb5901 | ||
|
|
fe965e7581 | ||
|
|
2fe3bfc077 | ||
|
|
49f2ef515b | ||
|
|
8b2550c9cf | ||
|
|
d5c892f238 | ||
|
|
ef8ac5e6db | ||
|
|
ba1c62469d | ||
|
|
44460f23cf | ||
|
|
044599c6ef | ||
|
|
6874e0b689 | ||
|
|
1fd10711a5 | ||
|
|
7bea3d1d6a | ||
|
|
283789c1f4 | ||
|
|
5361e2c08a | ||
|
|
178f3eb745 | ||
|
|
e8ccb5eb15 | ||
|
|
89e141ca3e | ||
|
|
99b5063489 | ||
|
|
02df071b4c | ||
|
|
5725f7b44e | ||
|
|
bcad4a75cf | ||
|
|
e85b24df57 | ||
|
|
00d79eb98a | ||
|
|
4fe0816ca0 | ||
|
|
675d4b9a3c | ||
|
|
00f565bc11 | ||
|
|
4b98b11996 | ||
|
|
57ad523b83 | ||
|
|
21504859c8 | ||
|
|
a9db1da90d | ||
|
|
96672e8ad5 | ||
|
|
628da5117a | ||
|
|
7202d1cf7c | ||
|
|
c1249e3ad1 | ||
|
|
f18ec0407f | ||
|
|
5483c7bc47 | ||
|
|
2fa94f111f | ||
|
|
5cbc168441 | ||
|
|
6b93106575 | ||
|
|
112a9c78b9 | ||
|
|
d09694ff61 | ||
|
|
76c70c2b89 | ||
|
|
ccc2fb6076 | ||
|
|
d1f69878f6 | ||
|
|
8495cc99e1 | ||
|
|
be0882a148 | ||
|
|
36a44a35cb | ||
|
|
975d5407a1 | ||
|
|
a4faa9091e | ||
|
|
05851de46c | ||
|
|
af28f01bfd | ||
|
|
381a121339 | ||
|
|
21a27a76be | ||
|
|
099349d91d | ||
|
|
dcefd523eb | ||
|
|
082e379412 | ||
|
|
834fa94f85 | ||
|
|
63400c9afd | ||
|
|
18cb02cbd9 | ||
|
|
4219720e13 | ||
|
|
a6afa05309 | ||
|
|
d8703162be | ||
|
|
11ffe09439 | ||
|
|
40aa1e8b66 | ||
|
|
df98e9cef9 | ||
|
|
b874f58667 | ||
|
|
dd9a01e3b2 | ||
|
|
2b787b1912 | ||
|
|
10de76e6c7 | ||
|
|
dd97b29dec | ||
|
|
85c22dd27b | ||
|
|
e1fbee8ebc | ||
|
|
b8f04d7af9 | ||
|
|
14dee9155d | ||
|
|
0fb3ea7a68 | ||
|
|
cc68d71ac2 | ||
|
|
0e631e20da | ||
|
|
b0531aeb73 | ||
|
|
09a37a8f10 | ||
|
|
c7a11e9860 | ||
|
|
7042306478 | ||
|
|
295c57535c | ||
|
|
a0d991e903 | ||
|
|
548e5345ee | ||
|
|
f3fbaa5568 | ||
|
|
977c895378 | ||
|
|
4b805d64bc | ||
|
|
028ce2b417 | ||
|
|
de8f1854f1 | ||
|
|
2fce1a9b42 | ||
|
|
e7591de2ec | ||
|
|
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 | ||
|
|
9ac6b67e18 | ||
|
|
de0c5a0d96 | ||
|
|
72236e4aab | ||
|
|
6c37a63704 | ||
|
|
c567511cf1 | ||
|
|
96bfba93f8 | ||
|
|
890870cd57 | ||
|
|
ee13457b2f | ||
|
|
8ae680547a | ||
|
|
40929d80c5 | ||
|
|
8f34c42e88 | ||
|
|
6c32845740 | ||
|
|
0b7deaa3d8 | ||
|
|
a34e109451 | ||
|
|
c0d0d909fc | ||
|
|
562004f198 | ||
|
|
7f8ac83dc3 | ||
|
|
594557e000 | ||
|
|
79214fd287 | ||
|
|
4a1e6de303 | ||
|
|
79cfd9b151 | ||
|
|
5cf0e24f43 |
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -42,3 +42,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -1,12 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# GridCraft — instructions for agents
|
||||
|
||||
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
Read [`ROADMAP.md`](ROADMAP.md) and [`docs/gaps.md`](docs/gaps.md). `plan/` is an optional gitignored notebook (status, execution notes, `plan/excel/*.md`). Do not ask for it and do not stop when it is absent. Screenshots of Microsoft software stay in that notebook and are never committed.
|
||||
|
||||
## Assets and iconography — absolute rule
|
||||
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
|
||||
@@ -35,7 +85,6 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
@@ -44,8 +93,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and, when available, compare with `plan/excel/observed-ui.md`. Without that optional notebook, verify the intended behaviour against the task and committed documentation.
|
||||
- macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
@@ -70,3 +119,5 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -1,56 +1 @@
|
||||
# GridCraft — instructions for agents
|
||||
|
||||
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Assets and iconography — absolute rule
|
||||
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
|
||||
- Every asset in the repo is original work by GridCraft contributors, or public domain / CC0, or open source / Creative Commons that allows redistribution (or licensed open source by the contributor who created it).
|
||||
- Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md) (author, source, licence). `cargo xtask assets` enforces it. Adding an asset without a row is a bug.
|
||||
- Prefer art generated in code: the UI icon set (`crates/ui-egui/src/icons.rs`), table and cell style palettes, themes and sample workbooks are drawn/defined in code and original.
|
||||
- Never copy Office's icons, ribbon artwork, theme XML, table-style definitions, templates, clip art or wording beyond feature names. Excel feature *names* (menu labels, function names) are fine.
|
||||
- Screenshots of Microsoft software are never committed (they live only in `plan/excel/screenshots/`).
|
||||
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
||||
|
||||
## Never crash
|
||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
|
||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+64
-13
@@ -220,7 +220,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -995,6 +995,26 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
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=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1346,6 +1366,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1413,6 +1446,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1478,7 +1531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2131,6 +2184,7 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
"gridcraft-chart",
|
||||
@@ -2152,6 +2206,8 @@ dependencies = [
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"gridcraft-engine",
|
||||
@@ -3507,7 +3563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3963,7 +4019,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4348,7 +4404,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4407,7 +4463,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5158,7 +5214,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6151,7 +6207,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6967,11 +7023,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+11
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **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)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 281 / 296 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
|
||||
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
|
||||
@@ -135,6 +135,8 @@ 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 |
|
||||
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
|
||||
@@ -176,6 +178,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Set precision as displayed is applied: with the option on, formula results, spills and numbers typed or reformatted are stored as their number format shows them and formulas calculate from those values; `formulas.calculationOptions` gains `precisionAsDisplayed` (turning it on rounds existing constants; locked constants on a protected sheet keep their value; numbers written to a new sheet are rounded too) (#1343) |
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
@@ -184,6 +187,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) |
|
||||
@@ -194,6 +198,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-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) |
|
||||
@@ -257,6 +263,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-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 |
|
||||
| 2026-11-02 | minor | Progress log: Python cells as data |
|
||||
@@ -286,6 +294,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: Set precision as displayed applied in calculation (#1343) |
|
||||
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
|
||||
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
|
||||
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
|
||||
|
||||
@@ -31,20 +31,41 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
|
||||
}
|
||||
|
||||
/// Upper-cased defined names whose formula is volatile, directly or through other names.
|
||||
///
|
||||
/// Volatility flows backwards along name references: each name is parsed and walked once, and a
|
||||
/// worklist carries volatility from a name to the names that use it. Rescanning every name until
|
||||
/// nothing changes was quadratic: a file listing its names so that each pass marked only one more
|
||||
/// did one full pass per name.
|
||||
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
|
||||
let parsed: Vec<(String, Option<Expr>)> =
|
||||
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
|
||||
let mut set = HashSet::new();
|
||||
// Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point.
|
||||
for _ in 0..=parsed.len() {
|
||||
let before = set.len();
|
||||
for (name, e) in &parsed {
|
||||
if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) {
|
||||
set.insert(name.clone());
|
||||
// Upper-cased name -> the names whose formulas refer to it.
|
||||
let mut users: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut queue = Vec::new();
|
||||
let none = HashSet::new();
|
||||
for n in &wb.names {
|
||||
let name = n.name.to_ascii_uppercase();
|
||||
let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue };
|
||||
if is_volatile(&e, &none) {
|
||||
if set.insert(name.clone()) {
|
||||
queue.push(name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if set.len() == before {
|
||||
break;
|
||||
let mut used = BTreeSet::new();
|
||||
e.walk(&mut |x| {
|
||||
if let Expr::Name(r) = x {
|
||||
used.insert(r.to_ascii_uppercase());
|
||||
}
|
||||
});
|
||||
for r in used {
|
||||
users.entry(r).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
while let Some(v) = queue.pop() {
|
||||
for user in users.remove(&v).unwrap_or_default() {
|
||||
if set.insert(user.clone()) {
|
||||
queue.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
|
||||
@@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024;
|
||||
/// Most zip entries looked at while validating a written package.
|
||||
const MAX_ENTRIES: usize = 200_000;
|
||||
|
||||
/// Decompressed bytes that comparing one package's relationship kinds may read in total: the
|
||||
/// relationship parts themselves and the targets whose content is checked.
|
||||
const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
|
||||
/// a cache Excel rebuilds, and GridCraft never writes it.
|
||||
pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];
|
||||
@@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
enum PartError {
|
||||
/// Reading the part would spend more than the remaining budget.
|
||||
OverBudget,
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`.
|
||||
fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result<Vec<u8>, PartError> {
|
||||
let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?;
|
||||
// One byte past what is left shows that the part doesn't fit.
|
||||
let limit = MAX_XML_PART.min(budget.saturating_add(1));
|
||||
let mut buf = Vec::new();
|
||||
// Bytes read before a decompression error still count against the budget.
|
||||
let res = f.take(limit).read_to_end(&mut buf);
|
||||
let read = buf.len() as u64;
|
||||
if read > *budget {
|
||||
*budget = 0;
|
||||
return Err(PartError::OverBudget);
|
||||
}
|
||||
*budget -= read;
|
||||
res.map_err(|_| PartError::Unreadable)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Whether the producer flagged its cached results as stale with `<calcPr fullCalcOnLoad="1">`:
|
||||
/// libraries that don't calculate write it, and store 0 or nothing as every formula's value.
|
||||
pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool {
|
||||
@@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
|
||||
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
|
||||
pub(super) fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
|
||||
relationship_kinds_within(bytes, RELATIONSHIP_BUDGET)
|
||||
}
|
||||
|
||||
/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship
|
||||
/// parts alone need more: their kinds can't be listed completely.
|
||||
fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
// Several relationships name the same part; each fresh check decompressed it again, so
|
||||
// repeated references to one highly compressible part multiplied the work. The verdict is
|
||||
// remembered per (part, named content) and the decompression the package can cause is
|
||||
// budgeted as a whole (finding: repeated decompression).
|
||||
// budgeted as a whole, the relationship parts included: many large, unreferenced rels parts
|
||||
// would otherwise each spend a full per-part allowance.
|
||||
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
||||
let mut budget: u64 = 512 * 1024 * 1024;
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
|
||||
let xml = match read_zip_part_within(&mut zip, n, &mut budget) {
|
||||
Ok(xml) => xml,
|
||||
Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")),
|
||||
Err(PartError::Unreadable) => continue,
|
||||
};
|
||||
let Ok(els) = xml_elements(&xml) else { continue };
|
||||
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
|
||||
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
|
||||
let target = attr(attrs, "Target").unwrap_or("");
|
||||
@@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str,
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A package with `count` relationship parts of `size` bytes each, none of them describing an
|
||||
/// existing part.
|
||||
fn many_rels(count: usize, size: usize) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let head = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom" Target="https://example.com" TargetMode="External"/>"#;
|
||||
let tail = "</Relationships>";
|
||||
let filler = " ".repeat(size.saturating_sub(head.len() + tail.len()));
|
||||
for i in 0..count {
|
||||
z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap();
|
||||
z.write_all(head.as_bytes()).unwrap();
|
||||
z.write_all(filler.as_bytes()).unwrap();
|
||||
z.write_all(tail.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_parts_count_against_the_decompression_budget() {
|
||||
// Each part fits the per-part allowance, but together they need more than the budget.
|
||||
let bytes = many_rels(8, 64 * 1024);
|
||||
assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err());
|
||||
let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap();
|
||||
assert_eq!(kinds.get("custom").map(String::as_str), Some("custom"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,28 @@ fn volatile_names_propagate() {
|
||||
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_names_propagate_through_a_long_chain_in_one_walk() {
|
||||
// Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan
|
||||
// until nothing changes marked one name per pass (quadratic). Every name in the chain is
|
||||
// volatile; a name off the chain is not.
|
||||
const LEN: usize = 5_000;
|
||||
let mut wb = book(&[("A1", "=1", n(1.0))]);
|
||||
let name =
|
||||
|name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
for i in 0..LEN {
|
||||
let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) };
|
||||
wb.names.push(name(format!("Chain{i}"), formula));
|
||||
}
|
||||
wb.names.push(name("Plain".into(), "Chain0*0+Other".into()));
|
||||
wb.names.push(name("Other".into(), "1".into()));
|
||||
let set = volatile_names(&wb);
|
||||
assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}"))));
|
||||
assert!(set.contains("PLAIN"));
|
||||
assert!(!set.contains("OTHER"));
|
||||
assert_eq!(set.len(), LEN + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_of_a_simple_book_is_clean() {
|
||||
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
|
||||
|
||||
@@ -16,6 +16,10 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
# 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 = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -26,4 +30,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,64 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
|
||||
//! Host modes: a page that runs GridCraft (as an iframe, or in its own document) opens workbooks
|
||||
//! in it and stores what it saves instead of the browser downloading. The messages and the two
|
||||
//! transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the other Craft
|
||||
//! apps' bridges, with the `gridcraft:` prefix. This module is GridCraft's part of it.
|
||||
//!
|
||||
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
|
||||
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
|
||||
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
|
||||
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
|
||||
//! as PDF. The parent stores it and reports failures itself.
|
||||
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `gridcraft:ready` once the app is listening; send a file now. GridCraft reads bytes, so it
|
||||
//! says `file: false` and a host sends `bytes`.
|
||||
//! - `gridcraft:open` (from the host): `{ name, bytes }`. It opens like a workbook picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `gridcraft:save` (to the host): `{ name, saveAs, bytes }` for every file GridCraft would
|
||||
//! otherwise download: Save and Save As (an .xlsx workbook) and Export as PDF. The host stores
|
||||
//! it and reports failures itself. GridCraft doesn't tell the host which saves make a file the
|
||||
//! document, so `saveAs` is always `false`.
|
||||
//! - `gridcraft:dirty` whenever unsaved changes appear or go away, `gridcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the page
|
||||
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
|
||||
//! In both host modes the page starts with an empty workbook instead of a sample, saves and PDF
|
||||
//! exports are handed to the page rather than downloaded, and `?author=` names the user for new
|
||||
//! notes and comments.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
|
||||
/// counts as saved under that name, as it would after Save on the desktop.
|
||||
use crate::hostmode::open_name;
|
||||
|
||||
/// The app id in the host messages (`gridcraft:ready`).
|
||||
const APP: &str = "gridcraft";
|
||||
|
||||
/// Saved files that count as a workbook: the document is then clean under the name it was saved as.
|
||||
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
|
||||
|
||||
/// The largest workbook taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
/// Names of the files handed to the parent since the last frame.
|
||||
host: Rc<Host>,
|
||||
/// Names of the files handed to the host since the last frame.
|
||||
saved: Rc<RefCell<Vec<String>>>,
|
||||
/// The unsaved-changes state last reported to the parent.
|
||||
reported: Rc<Cell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
|
||||
/// The bridge for this page, or `None` when GridCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), saved: Rc::default() })
|
||||
}
|
||||
|
||||
/// Send saved and exported files to the parent instead of downloading them, and hear files
|
||||
/// Send saved and exported files to the host instead of downloading them, and hear files
|
||||
/// from it.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
@@ -72,15 +79,16 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// After each frame: a workbook handed to the parent this frame counts as saved under the
|
||||
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
|
||||
/// After each frame: a workbook handed to the host this frame counts as saved under the
|
||||
/// name it was saved as, and the host hears when unsaved changes appear or go away (the
|
||||
/// crate does not repeat a state it already reported).
|
||||
pub fn sync(&self, app: &mut SheetApp) {
|
||||
let saved = std::mem::take(&mut *self.saved.borrow_mut());
|
||||
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
|
||||
@@ -91,76 +99,45 @@ impl Bridge {
|
||||
doc.title = name.clone();
|
||||
}
|
||||
}
|
||||
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
|
||||
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
self.reported.set(Some(dirty));
|
||||
}
|
||||
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
|
||||
let name = file_name(path);
|
||||
self.host.post_save(&name, bytes, None, false)?;
|
||||
self.saved.borrow_mut().push(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("gridcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim).
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; GridCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((open_name(name), array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn is_workbook(name: &str) -> bool {
|
||||
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::prefs`]): the name the bridge fills in when a host's open request has none.
|
||||
//! Which pages host the app is `craft-web-host`'s.
|
||||
|
||||
/// The file name used when an open request carries no usable one.
|
||||
pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";
|
||||
|
||||
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
|
||||
/// name and falls back to this).
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// A usable file name for an open request: the cleaned name the crate hands over, or
|
||||
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
|
||||
pub fn open_name(name: &str) -> String {
|
||||
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Budget.xlsx"), "Budget.xlsx");
|
||||
assert_eq!(open_name("Sheet2!Book.xlsx"), "Sheet2!Book.xlsx");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,24 @@
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
|
||||
//! notes and comments. `?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`).
|
||||
//! 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))]
|
||||
mod hostmode;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
@@ -37,9 +37,12 @@ pub fn start() {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
|
||||
// workbooks in it and stores what it saves (`host`).
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing GridCraft (such
|
||||
// as a Nextcloud app), opens workbooks in it and stores what it saves (`host`). Unframed
|
||||
// there is no parent, so that URL on its own stays the normal app with downloads.
|
||||
// `data-host="page"` on the canvas opts a page in instead: it runs GridCraft in its own
|
||||
// document and talks over DOM `CustomEvent`s on the canvas.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
@@ -48,7 +51,7 @@ pub fn start() {
|
||||
Box::new(move |cc| {
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut session = Session::new();
|
||||
// In host mode the parent sends the workbook to edit; no sample meanwhile.
|
||||
// In host mode the host sends the workbook to edit; no sample meanwhile.
|
||||
let q = if app_bridge.is_some() { String::new() } else { query() };
|
||||
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
|
||||
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
|
||||
@@ -96,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 }))
|
||||
}),
|
||||
)
|
||||
@@ -193,7 +197,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
|
||||
@@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
|
||||
|
||||
@@ -154,7 +154,7 @@ fn read_clipboard<T>(
|
||||
fn services() -> Services {
|
||||
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
|
||||
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
|
||||
let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone());
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
let mut clipboard = clipboard.borrow_mut();
|
||||
@@ -165,9 +165,13 @@ fn services() -> Services {
|
||||
})),
|
||||
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
|
||||
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
|
||||
paste_image: Some(Box::new(move || {
|
||||
let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?;
|
||||
image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned())
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
|
||||
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
|
||||
.add_filter("Excel 97-2003 Workbook (import)", &["xls"])
|
||||
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-3
@@ -115,6 +115,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> {
|
||||
@@ -325,6 +331,11 @@ impl<'h> Evaluator<'h> {
|
||||
Expr::Array(rows) => {
|
||||
let h = rows.len();
|
||||
let w = rows.first().map_or(0, Vec::len);
|
||||
// A ragged literal is an error either way; one wide row and many empty ones must
|
||||
// not reserve their rows-times-width product first.
|
||||
if rows.iter().any(|row| row.len() != w) {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let mut data = Vec::with_capacity(h * w);
|
||||
for row in rows {
|
||||
for el in row {
|
||||
@@ -359,9 +370,10 @@ impl<'h> Evaluator<'h> {
|
||||
return v.clone();
|
||||
}
|
||||
let wb = self.host.workbook();
|
||||
// Sheet-qualified `Sheet1!Name`.
|
||||
let (scope_sheet, bare) = match n.split_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
|
||||
// Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as
|
||||
// is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`).
|
||||
let (scope_sheet, bare) = match n.rsplit_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(s) {
|
||||
Some(i) => (i, b),
|
||||
None => return err(CellError::Name),
|
||||
},
|
||||
|
||||
@@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
}
|
||||
}
|
||||
|
||||
/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the
|
||||
/// result grows as cells are made rather than being reserved for the whole broadcast up front.
|
||||
fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option<Value>) -> Option<Value> {
|
||||
Some(match (a, b) {
|
||||
(Value::Array(x), Value::Array(y)) => {
|
||||
let rows = broadcast_dim(x.rows, y.rows);
|
||||
let cols = broadcast_dim(x.cols, y.cols);
|
||||
if broadcast_len(rows, cols).is_none() {
|
||||
return Some(Value::Error(CellError::Num));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?);
|
||||
}
|
||||
}
|
||||
Value::from(Array { rows, cols, data })
|
||||
}
|
||||
(Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::<Option<_>>()? }),
|
||||
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::<Option<_>>()? }),
|
||||
(x, y) => f(x, y)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
|
||||
/// Each operand passes its own size cap, but their broadcast product can be far larger
|
||||
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
|
||||
@@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a
|
||||
/// compact formula can broadcast a long text across millions of cells
|
||||
/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the
|
||||
/// error for a result too large to hold, before the rest is built.
|
||||
pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) {
|
||||
return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES);
|
||||
}
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
|
||||
}
|
||||
|
||||
/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget`
|
||||
/// bytes.
|
||||
fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value {
|
||||
let mut used = 0usize;
|
||||
try_zip2(x, y, |a, b| {
|
||||
let v = binary_scalar(BinOp::Concat, a, b, sys);
|
||||
if let Value::Text(t) = &v {
|
||||
used = used.saturating_add(t.len());
|
||||
if used > budget {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
})
|
||||
.unwrap_or(Value::Error(CellError::Num))
|
||||
}
|
||||
|
||||
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
if let Value::Error(e) = a {
|
||||
return Value::Error(*e);
|
||||
@@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_CELLS, broadcast_len};
|
||||
use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2};
|
||||
|
||||
/// The broadcast stops at the first refused cell: nothing after it is visited.
|
||||
#[test]
|
||||
fn a_refused_cell_stops_the_broadcast() {
|
||||
let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect()));
|
||||
let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() });
|
||||
let mut calls = 0;
|
||||
let r = try_zip2(&col, &row, |a, _| {
|
||||
calls += 1;
|
||||
(calls <= 10).then(|| a.clone())
|
||||
});
|
||||
assert_eq!((r, calls), (None, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concatenating_arrays_stops_at_the_byte_budget() {
|
||||
let sys = gridcraft_core::DateSystem::default();
|
||||
let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect()));
|
||||
let long = Value::text("x".repeat(100));
|
||||
// 100 cells of 101 or 102 bytes each: about 10 KB in all.
|
||||
let r = concat_arrays(&col, &long, sys, 20_000);
|
||||
assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}");
|
||||
assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num));
|
||||
assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num));
|
||||
// Through the operator, a small result is unaffected.
|
||||
let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys);
|
||||
assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_len_caps_at_max_cells() {
|
||||
|
||||
@@ -383,6 +383,15 @@ 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(());
|
||||
}
|
||||
let mut error = None;
|
||||
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
|
||||
Ok(()) => true,
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod eval;
|
||||
mod precision;
|
||||
pub mod recalc;
|
||||
mod spill_index;
|
||||
mod trimrange;
|
||||
|
||||
pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
|
||||
pub use precision::DisplayedPrecision;
|
||||
pub use recalc::{Calc, Key, evaluate, now_serial};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! "Set precision as displayed" (File › Options › Advanced; `fullPrecision="0"` in `.xlsx`,
|
||||
//! CalcPrecision in `.xls`): with it on, a number is stored as its cell's number format shows it,
|
||||
//! and formulas read those rounded values. The rounding itself is the number format crate's
|
||||
//! ([`gridcraft_numfmt::NumberFormat::round_as_displayed`]); this module looks up each cell's
|
||||
//! format, parsing every style's code once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{StyleId, Workbook};
|
||||
use gridcraft_numfmt::NumberFormat;
|
||||
|
||||
/// Rounds values to their cell's displayed precision, remembering each style's parsed format.
|
||||
/// Styles don't change while it is used (one recalculation pass, one sweep of a workbook).
|
||||
#[derive(Default)]
|
||||
pub struct DisplayedPrecision {
|
||||
/// Parsed format per style; `None` for General, which keeps the full value.
|
||||
formats: HashMap<StyleId, Option<NumberFormat>>,
|
||||
}
|
||||
|
||||
impl DisplayedPrecision {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `n` as the format of `c` on sheet `sheet` displays it (the cell's own style, else its
|
||||
/// row's, else its column's, as the grid shows it).
|
||||
pub fn round(&mut self, wb: &Workbook, sheet: usize, c: CellRef, n: f64) -> f64 {
|
||||
let Some(sh) = wb.sheet(sheet) else { return n };
|
||||
let id = sh.style_id(c);
|
||||
let fmt = self.formats.entry(id).or_insert_with(|| {
|
||||
let f = NumberFormat::parse_cached(wb.styles.get(id).num_fmt.as_str());
|
||||
(!f.is_general()).then_some(f)
|
||||
});
|
||||
match fmt {
|
||||
Some(f) => f.round_as_displayed(n),
|
||||
None => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`DisplayedPrecision::round`] for a value: only numbers change.
|
||||
pub fn round_value(&mut self, wb: &Workbook, sheet: usize, c: CellRef, v: Value) -> Value {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let r = self.round(wb, sheet, c, n);
|
||||
// Bit-equal results keep the value as it was (no needless change for undo/diffs).
|
||||
if r.to_bits() == n.to_bits() { Value::Number(n) } else { Value::Number(r) }
|
||||
}
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
+359
-47
@@ -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;
|
||||
@@ -24,7 +35,7 @@ mod pass_host;
|
||||
#[cfg(test)]
|
||||
use graph::Intervals;
|
||||
pub use graph::{FxHasher, Graph, Key};
|
||||
use graph::{HashMap, HashSet, Node};
|
||||
use graph::{HashMap, HashSet, Node, Taken};
|
||||
use pass_host::{PassHost, spills_as_before};
|
||||
|
||||
/// Calculation state kept beside a workbook.
|
||||
@@ -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,23 +393,18 @@ 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.
|
||||
// Spill areas of changed anchors also change. They stay rectangles: one seed per spilled
|
||||
// cell listed the formulas reading the area once per cell.
|
||||
let mut areas: Vec<(usize, RangeRef)> = cleared;
|
||||
for &k in changed {
|
||||
if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) {
|
||||
seeds.extend(r.iter().map(|c| (k.0, c)));
|
||||
areas.push((k.0, *r));
|
||||
}
|
||||
}
|
||||
for (si, r) in &cleared {
|
||||
seeds.extend(r.iter().map(|c| (*si, c)));
|
||||
}
|
||||
let t0 = prof_now();
|
||||
let dirty = self.dirty_closure(wb, &seeds);
|
||||
let dirty = self.dirty_closure(wb, &seeds, &areas);
|
||||
let t1 = prof_now();
|
||||
let n = dirty.len();
|
||||
self.run(wb, dirty, false);
|
||||
@@ -311,16 +413,39 @@ impl Calc {
|
||||
}
|
||||
}
|
||||
|
||||
fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec<Key> {
|
||||
/// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds
|
||||
/// that are formulas, and everything that reads them, directly or through other formulas.
|
||||
fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec<Key> {
|
||||
let mut seen: HashSet<Key> = HashSet::default();
|
||||
let mut queue: VecDeque<Key> = VecDeque::new();
|
||||
// Each range-index entry is listed once in the whole walk: many changed cells under one
|
||||
// broad reference (or many formulas inside one) would otherwise list every formula reading
|
||||
// it once per cell, which squared the work.
|
||||
let mut taken = Taken::default();
|
||||
let mut deps = Vec::new();
|
||||
for k in seeds {
|
||||
if self.graph.nodes.contains_key(k) && seen.insert(*k) {
|
||||
queue.push_back(*k);
|
||||
}
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents(k.0, k.1, &mut deps);
|
||||
for d in deps {
|
||||
deps.clear();
|
||||
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
|
||||
for d in deps.drain(..) {
|
||||
if seen.insert(d) {
|
||||
queue.push_back(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
for &(si, r) in areas {
|
||||
if let Some(sh) = wb.sheet(si) {
|
||||
for (c, _) in sh.cells.iter_range(r) {
|
||||
if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) {
|
||||
queue.push_back((si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
deps.clear();
|
||||
self.graph.dependents_of_range(si, r, &mut deps);
|
||||
for d in deps.drain(..) {
|
||||
if seen.insert(d) {
|
||||
queue.push_back(d);
|
||||
}
|
||||
@@ -331,10 +456,9 @@ impl Calc {
|
||||
queue.push_back(*k);
|
||||
}
|
||||
}
|
||||
let mut deps = Vec::new();
|
||||
while let Some(k) = queue.pop_front() {
|
||||
deps.clear();
|
||||
self.graph.dependents(k.0, k.1, &mut deps);
|
||||
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
|
||||
for d in deps.drain(..) {
|
||||
if seen.insert(d) {
|
||||
queue.push_back(d);
|
||||
@@ -411,7 +535,7 @@ impl Calc {
|
||||
if spill_changes.is_empty() {
|
||||
break;
|
||||
}
|
||||
dirty = self.dirty_closure(wb, &spill_changes);
|
||||
dirty = self.dirty_closure(wb, &spill_changes, &[]);
|
||||
}
|
||||
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
|
||||
// Iterative calculation reports none.
|
||||
@@ -433,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 {
|
||||
@@ -466,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);
|
||||
@@ -492,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
|
||||
}
|
||||
@@ -556,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 {
|
||||
@@ -573,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>) {
|
||||
@@ -615,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();
|
||||
@@ -691,6 +860,149 @@ pub(crate) mod tests {
|
||||
assert!(visited < 64, "visited {visited} nodes for no match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untaken_queries_list_each_interval_once_in_all() {
|
||||
// 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000
|
||||
// each time (5 million matches); sharing `taken`, each range is listed once in all.
|
||||
let n = 5_000u32;
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
for t in 0..1_000 {
|
||||
iv.insert(0, n, t);
|
||||
}
|
||||
for r in 0..n {
|
||||
iv.insert(r, r, 10_000 + r);
|
||||
}
|
||||
iv.settle();
|
||||
let mut taken = Vec::new();
|
||||
let mut listed = Vec::new();
|
||||
for row in 0..=n + 3 {
|
||||
iv.query_untaken(row, row, &mut taken, &mut |t| {
|
||||
listed.push(*t);
|
||||
true
|
||||
});
|
||||
}
|
||||
listed.sort_unstable();
|
||||
let want: Vec<u32> = (0..1_000).chain(10_000..10_000 + n).collect();
|
||||
assert_eq!(listed, want);
|
||||
// An entry the caller declines is offered again.
|
||||
let mut taken = Vec::new();
|
||||
let mut offered = 0;
|
||||
for _ in 0..3 {
|
||||
iv.query_untaken(7, 7, &mut taken, &mut |t| {
|
||||
if *t == 10_007 {
|
||||
offered += 1;
|
||||
}
|
||||
*t != 10_007
|
||||
});
|
||||
}
|
||||
assert_eq!(offered, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_closure_matches_a_walk_of_every_cell() {
|
||||
// Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some
|
||||
// removed or left unsettled: the closure equals the one found by listing every dependent
|
||||
// of every changed cell, area cells included, as before `taken` and areas existed.
|
||||
use super::{HashSet, Node};
|
||||
use crate::eval::Area;
|
||||
use std::collections::VecDeque;
|
||||
let mut s = 0x2545_F491_4F6C_DD1D;
|
||||
for case in 0..40 {
|
||||
let mut wb = Workbook::new();
|
||||
let mut calc = Calc::new();
|
||||
let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32;
|
||||
let mut keys: Vec<Key> = Vec::new();
|
||||
for _ in 0..300 {
|
||||
let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150)));
|
||||
let mut areas = Vec::new();
|
||||
for _ in 0..1 + pick(&mut s, 3) {
|
||||
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
|
||||
let end = match pick(&mut s, 3) {
|
||||
0 => start,
|
||||
1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)),
|
||||
_ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)),
|
||||
};
|
||||
areas.push(Area { sheet: 0, range: RangeRef::new(start, end) });
|
||||
}
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.cells.set(k.1, Cell::formula(Formula::new("=1")));
|
||||
}
|
||||
calc.graph.insert(k, Node { areas, dynamic: false });
|
||||
keys.push(k);
|
||||
if rng(&mut s).is_multiple_of(7) {
|
||||
calc.graph.settle();
|
||||
}
|
||||
}
|
||||
for _ in 0..30 {
|
||||
let k = keys[(rng(&mut s) % keys.len() as u64) as usize];
|
||||
calc.graph.remove(k);
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.cells.remove(k.1);
|
||||
}
|
||||
}
|
||||
if case % 2 == 0 {
|
||||
calc.graph.settle();
|
||||
}
|
||||
let seeds: Vec<Key> = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect();
|
||||
let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3))
|
||||
.map(|_| {
|
||||
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
|
||||
(0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80))))
|
||||
})
|
||||
.collect();
|
||||
let got: HashSet<Key> = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect();
|
||||
// The walk before: every area cell is a seed, and every cell lists all its dependents.
|
||||
let mut all = seeds.clone();
|
||||
for (si, r) in &areas {
|
||||
all.extend(r.iter().map(|c| (*si, c)));
|
||||
}
|
||||
let mut want: HashSet<Key> = HashSet::default();
|
||||
let mut queue: VecDeque<Key> = VecDeque::new();
|
||||
let mut deps = Vec::new();
|
||||
for k in &all {
|
||||
if calc.graph.nodes.contains_key(k) && want.insert(*k) {
|
||||
queue.push_back(*k);
|
||||
}
|
||||
deps.clear();
|
||||
calc.graph.dependents(k.0, k.1, &mut deps);
|
||||
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
|
||||
}
|
||||
while let Some(k) = queue.pop_front() {
|
||||
deps.clear();
|
||||
calc.graph.dependents(k.0, k.1, &mut deps);
|
||||
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
|
||||
}
|
||||
assert_eq!(got, want, "case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_spill_recalculates_formulas_reading_it_once_each() {
|
||||
// 300 formulas read the area an anchor spills into; clearing the anchor changes all of it
|
||||
// and every one of them is recalculated (they were listed once per spilled cell).
|
||||
let mut wb = Workbook::new();
|
||||
let mut calc = Calc::new();
|
||||
let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| {
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
if input.is_empty() {
|
||||
sh.cells.remove(at);
|
||||
} else {
|
||||
sh.cells.set(at, Cell::formula(Formula::new(input)));
|
||||
}
|
||||
}
|
||||
calc.cells_changed(wb, &[(0, at)]);
|
||||
};
|
||||
set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)");
|
||||
for r in 0..300 {
|
||||
set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)");
|
||||
}
|
||||
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0)));
|
||||
set(&mut wb, &mut calc, CellRef::new(0, 0), "");
|
||||
for r in 0..300 {
|
||||
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
|
||||
fn sum_rows(rows: u32) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
@@ -151,6 +151,54 @@ impl<T: Copy + Ord> Intervals<T> {
|
||||
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
|
||||
}
|
||||
|
||||
/// [`Intervals::query`] that skips entries an earlier call with the same `taken` already
|
||||
/// took (and whole subtrees of them), so that a series of queries lists each interval once in
|
||||
/// all. `f` answers whether it took the entry; one it declines is offered again later.
|
||||
/// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at
|
||||
/// most after [`Intervals::settle`]) are offered every time.
|
||||
pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec<bool>, f: &mut dyn FnMut(&T) -> bool) {
|
||||
for it in &self.pending {
|
||||
if it.0 <= b && it.1 >= a {
|
||||
f(&it.2);
|
||||
}
|
||||
}
|
||||
let p = self.items.partition_point(|it| it.0 <= b);
|
||||
if p == 0 {
|
||||
return;
|
||||
}
|
||||
if taken.len() != self.max_end.len() {
|
||||
*taken = vec![false; self.max_end.len()];
|
||||
}
|
||||
self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f);
|
||||
}
|
||||
|
||||
/// Returns whether everything under `node` has now been reported (or is dead).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool {
|
||||
if taken.get(node).copied().unwrap_or(true) {
|
||||
return true;
|
||||
}
|
||||
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
|
||||
return false;
|
||||
}
|
||||
let done = if hi - lo <= 1 {
|
||||
match (self.items.get(lo), self.dead.get(lo)) {
|
||||
(Some(it), Some(false)) if it.1 >= a => f(&it.2),
|
||||
(Some(_), Some(true)) => true,
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f);
|
||||
let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f);
|
||||
left && right
|
||||
};
|
||||
if done && let Some(slot) = taken.get_mut(node) {
|
||||
*slot = true;
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
|
||||
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
|
||||
return 1;
|
||||
@@ -168,6 +216,13 @@ impl<T: Copy + Ord> Intervals<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Taken {
|
||||
cols: HashMap<(usize, u32), Vec<bool>>,
|
||||
wide: HashMap<usize, Vec<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Graph {
|
||||
pub(super) nodes: HashMap<Key, Node>,
|
||||
@@ -302,6 +357,32 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the
|
||||
/// same `taken` already listed. A walk over many changed cells that a few broad ranges cover
|
||||
/// then lists those formulas once, instead of once per cell (which squared the work). The
|
||||
/// graph must not change while `taken` is in use.
|
||||
pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec<Key>) {
|
||||
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
|
||||
out.extend(v.iter().copied());
|
||||
}
|
||||
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
|
||||
v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| {
|
||||
out.push(*k);
|
||||
true
|
||||
});
|
||||
}
|
||||
if let Some(v) = self.wide_deps.get(&sheet) {
|
||||
// A wide entry is taken once it covers the cell's column too.
|
||||
v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| {
|
||||
let hit = *c0 <= c.col && c.col <= *c1;
|
||||
if hit {
|
||||
out.push(*k);
|
||||
}
|
||||
hit
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Formulas that read any cell of `range` (a formula can be listed more than once).
|
||||
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
|
||||
if range.count() <= 4096 {
|
||||
|
||||
@@ -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>>,
|
||||
@@ -64,10 +70,17 @@ pub(super) struct PassHost<'a> {
|
||||
pub(super) tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
/// "Set precision as displayed" is on: results are rounded to their cells' formats before
|
||||
/// they are stored or read by other formulas.
|
||||
precision: Option<crate::DisplayedPrecision>,
|
||||
/// `stack_position()` where the outermost formula being calculated started.
|
||||
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.
|
||||
@@ -83,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,
|
||||
@@ -104,8 +118,10 @@ impl<'a> PassHost<'a> {
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new),
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
sums: HashMap::default(),
|
||||
python: HashMap::default(),
|
||||
}
|
||||
}
|
||||
@@ -119,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).
|
||||
@@ -157,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.
|
||||
@@ -344,20 +377,43 @@ impl PassHost<'_> {
|
||||
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
};
|
||||
// Precision as displayed: each element takes the format of the cell it lands in.
|
||||
let a = match self.precision.as_mut() {
|
||||
Some(p) if a.data.iter().any(|x| matches!(x, Value::Number(_))) => {
|
||||
let cols = a.cols.max(1);
|
||||
let data = a
|
||||
.data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
let at = CellRef::new(k.1.row.saturating_add((i / cols) as u32), k.1.col.saturating_add((i % cols) as u32));
|
||||
p.round_value(self.wb, k.0, at, x.clone())
|
||||
})
|
||||
.collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
}
|
||||
_ => a,
|
||||
};
|
||||
// A dynamic-array formula anchors a spill range even while its result is one
|
||||
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
|
||||
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
|
||||
// returns a scalar never reaches this arm, so it does not become an anchor.
|
||||
self.spill_index.insert(k, a.rows, a.cols);
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v @ Value::Number(_) => match self.precision.as_mut() {
|
||||
Some(p) => p.round_value(self.wb, k.0, k.1, v),
|
||||
None => v,
|
||||
},
|
||||
v => v,
|
||||
};
|
||||
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
|
||||
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,
|
||||
@@ -377,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 {
|
||||
@@ -388,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,
|
||||
@@ -434,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);
|
||||
}
|
||||
@@ -518,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()
|
||||
}
|
||||
@@ -567,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -625,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;
|
||||
@@ -635,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) {
|
||||
@@ -646,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)]);
|
||||
}
|
||||
}
|
||||
@@ -1922,6 +1922,72 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
/// "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.
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_results_by_cell_format() {
|
||||
let fmt = |t: &mut T, at: &str, code: &str| {
|
||||
let id = t.wb.styles.intern(gridcraft_model::Style { num_fmt: gridcraft_model::NumFmt::new(code), ..Default::default() });
|
||||
t.wb.sheet_mut(0).unwrap().set_style(c(at), id);
|
||||
};
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=-130+657.91");
|
||||
t.set("A2", "=A1*10");
|
||||
t.set("A3", "=A1/7");
|
||||
// Cross-cell: E2 rounds what it reads from E1; E1 itself (General) keeps full precision.
|
||||
t.set("E1", "=10/3");
|
||||
t.set("E2", "=E1");
|
||||
t.set("E3", "=E2*10");
|
||||
t.set("E4", "=E1*10");
|
||||
// A spill: each element takes the format of the cell it lands in.
|
||||
t.set("F1", "=SEQUENCE(3,1,0.123,0.111)");
|
||||
t.set("G4", "=SUM(F1:F3)");
|
||||
t.set("H1", "=TRUE");
|
||||
t.set("H2", "=\"1.5\"");
|
||||
// A conditional format rounds by the sign's section, not the one displayed: `[<1]0.00;0`
|
||||
// shows 2+1/3 as `2` but stores 2.33, and stores -10/3 as -3.
|
||||
t.set("J1", "=2+1/3");
|
||||
t.set("J2", "=-10/3");
|
||||
t.set("K1", "=J1*3");
|
||||
t.set("K2", "=J2*3");
|
||||
fmt(&mut t, "J1", "[<1]0.00;0");
|
||||
fmt(&mut t, "J2", "[<1]0.00;0");
|
||||
fmt(&mut t, "A1", "0");
|
||||
fmt(&mut t, "A3", "0.0000");
|
||||
fmt(&mut t, "E2", "0");
|
||||
fmt(&mut t, "F1", "0.0");
|
||||
fmt(&mut t, "F2", "0.0");
|
||||
fmt(&mut t, "H1", "0");
|
||||
fmt(&mut t, "H2", "0");
|
||||
// Off: formats don't change values.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), -130.0 + 657.91);
|
||||
assert_eq!(t.num("E2"), 10.0 / 3.0);
|
||||
t.wb.calc.precision_as_displayed = true;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (528.0, 5280.0, 75.4286));
|
||||
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (10.0 / 3.0, 3.0, 30.0, 10.0 / 3.0 * 10.0));
|
||||
let sh = t.wb.sheet(0).unwrap();
|
||||
assert_eq!(
|
||||
(sh.value(c("F1")), sh.value(c("F2")), sh.value(c("F3"))),
|
||||
(Value::Number(0.1), Value::Number(0.2), Value::Number(0.123 + 0.111 * 2.0))
|
||||
);
|
||||
assert_eq!(t.num("G4"), 0.1 + 0.2 + (0.123 + 0.111 * 2.0));
|
||||
assert_eq!((t.num("J1"), t.num("J2"), t.num("K1"), t.num("K2")), (2.33, -3.0, 2.33 * 3.0, -9.0));
|
||||
// Booleans and text are left alone.
|
||||
assert_eq!((t.get("H1"), t.get("H2")), (Value::Bool(true), Value::from("1.5")));
|
||||
// An edit recalculates through the rounded values too.
|
||||
t.set("A1", "=-130+657.41");
|
||||
fmt(&mut t, "A1", "0");
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("A1"))]);
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (527.0, 5270.0));
|
||||
// Off again: a recalculation brings back full precision.
|
||||
t.wb.calc.precision_as_displayed = false;
|
||||
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
|
||||
@@ -2313,6 +2379,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+100
-14
@@ -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};
|
||||
|
||||
@@ -19,7 +20,7 @@ mod trend;
|
||||
|
||||
use cartesian::{cartesian, scatter};
|
||||
use frame::{Bx, axis_titles, chart_title};
|
||||
use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall};
|
||||
use kinds::{box_whisker, doughnut, funnel, histogram, pie, radar, stock, treemap, waterfall};
|
||||
use legend::draw_legend;
|
||||
pub use legend::{Swatch, legend_entries};
|
||||
pub use trend::supports_trendline;
|
||||
@@ -44,31 +45,66 @@ 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
|
||||
/// capped instead of the chart dropping rings wholesale.
|
||||
pub(crate) const MAX_WEDGES: usize = 2_048;
|
||||
|
||||
struct Ctx<'a> {
|
||||
out: Vec<Prim>,
|
||||
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() {
|
||||
@@ -96,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(),
|
||||
@@ -121,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);
|
||||
@@ -153,7 +239,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
use ChartKind::*;
|
||||
match chart.kind {
|
||||
Pie => pie(ctx, chart, data, area, 0.0, false),
|
||||
Doughnut => pie(ctx, chart, data, area, 0.5, false),
|
||||
Doughnut => doughnut(ctx, chart, data, area),
|
||||
Sunburst => pie(ctx, chart, data, area, 0.2, true),
|
||||
Treemap => treemap(ctx, chart, data, area),
|
||||
Funnel => funnel(ctx, chart, data, area),
|
||||
|
||||
@@ -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();
|
||||
@@ -96,8 +200,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut row = Vec::with_capacity(ncat);
|
||||
for j in 0..ncat {
|
||||
// A series without values has no spans: 255 empty series over 20,000 categories would
|
||||
// otherwise fill ~5 million entries.
|
||||
let n = if s.values.is_empty() { 0 } else { ncat };
|
||||
let mut row = Vec::with_capacity(n);
|
||||
for j in 0..n {
|
||||
let v = s.values.get(j).copied().flatten();
|
||||
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
|
||||
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
|
||||
@@ -138,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))
|
||||
@@ -184,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();
|
||||
@@ -275,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;
|
||||
@@ -313,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();
|
||||
@@ -450,3 +556,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_series_get_no_span_entries() {
|
||||
let empty = SeriesData::default();
|
||||
let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() };
|
||||
let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() };
|
||||
let fams = [Fam::Col; 3];
|
||||
let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS);
|
||||
assert_eq!(spans.iter().map(Vec::len).collect::<Vec<_>>(), [0, MAX_CATS, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
|
||||
use super::legend::{cat_label, point_color};
|
||||
use super::{
|
||||
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData,
|
||||
TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
|
||||
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, MAX_SERIES, MAX_WEDGES, Prim,
|
||||
QuartileMethod, Rgba, STOCK, SeriesData, TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- histogram
|
||||
@@ -163,6 +163,66 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
|
||||
// ---------------------------------------------------------------- pie, doughnut, sunburst
|
||||
|
||||
/// At most `cap` of the largest values, in category order. When a ring has more categories than
|
||||
/// its share of the wedge budget allows, the big slices survive rather than the first ones.
|
||||
fn largest_slices(vals: &[f64], cap: usize) -> Vec<(usize, f64)> {
|
||||
let mut idx: Vec<usize> = (0..vals.len()).collect();
|
||||
if idx.len() > cap {
|
||||
// Partition descends by value, so the first `cap` entries are the largest ones.
|
||||
idx.select_nth_unstable_by(cap - 1, |a, b| vals[*b].total_cmp(&vals[*a]));
|
||||
idx.truncate(cap);
|
||||
idx.sort_unstable();
|
||||
}
|
||||
idx.into_iter().map(|j| (j, vals[j])).collect()
|
||||
}
|
||||
|
||||
/// One ring's geometry and separator stroke: the slices of a ring differ only in angle and colour.
|
||||
struct Ring {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
outer: f32,
|
||||
inner: f32,
|
||||
stroke: Option<(Rgba, f32)>,
|
||||
}
|
||||
|
||||
/// A wedge of `ring`, split at 12 o'clock when it crosses the end of the circle.
|
||||
fn wedge(ctx: &mut Ctx, ring: &Ring, a0: f32, sweep: f32, fill: Rgba) {
|
||||
let Ring { cx, cy, outer, inner, stroke } = *ring;
|
||||
if sweep <= 1e-4 {
|
||||
return;
|
||||
}
|
||||
// A slice that starts past 12 o'clock still has to be split against 12 o'clock, not against
|
||||
// its unwrapped start: otherwise the half after TAU is drawn from 0 over its neighbours.
|
||||
let a0 = a0.rem_euclid(TAU);
|
||||
let a1 = a0 + sweep;
|
||||
if a1 <= TAU {
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1, fill, stroke });
|
||||
return;
|
||||
}
|
||||
// The halves are filled without a stroke: each would outline its own radial edge at 12
|
||||
// o'clock, painting the white separator straight through the slice.
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1: TAU, fill, stroke: None });
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0: 0.0, a1: a1 - TAU, fill, stroke: None });
|
||||
if let Some((color, width)) = stroke {
|
||||
// One outline for the whole slice: the outer and inner arcs run through 12 o'clock, and
|
||||
// the two radial edges at the slice's own start and end close the path.
|
||||
let arc = |r: f32, from: f32, to: f32| -> Vec<[f32; 2]> {
|
||||
let steps = ((r.abs() * (to - from).abs() / 2.0).ceil() as usize).clamp(4, 512);
|
||||
let at = |a: f32| [cx + r * a.sin(), cy - r * a.cos()];
|
||||
(0..=steps).map(|k| at(from + (to - from) * k as f32 / steps as f32)).collect()
|
||||
};
|
||||
let mut pts = arc(outer, a0, a1);
|
||||
if inner > 0.0 {
|
||||
pts.extend(arc(inner, a1, a0));
|
||||
} else {
|
||||
pts.push([cx, cy]);
|
||||
}
|
||||
let start = pts[0];
|
||||
pts.push(start);
|
||||
ctx.out.push(Prim::Line { pts, color, width, dash: false });
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) {
|
||||
let Some(s) = data.series.first() else { return };
|
||||
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
|
||||
@@ -176,14 +236,15 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
|
||||
return;
|
||||
}
|
||||
let ri = r * inner;
|
||||
let ring = Ring { cx, cy, outer: r, inner: ri, stroke: Some((WHITE, 1.5)) };
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let mut a = 0.0f32;
|
||||
let mut a = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
|
||||
let mut labels = Vec::new();
|
||||
for (j, v) in vals.iter().enumerate() {
|
||||
let sweep = (*v / total) as f32 * TAU;
|
||||
if sweep > 1e-4 {
|
||||
let fill = point_color(data, j);
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) });
|
||||
wedge(ctx, &ring, a, sweep, fill);
|
||||
if (chart.data_labels || names) && vals.len() <= MAX_LABELS {
|
||||
let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) };
|
||||
let mid = a + sweep / 2.0;
|
||||
@@ -201,21 +262,97 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's doughnut: every series is one concentric ring, the first series innermost (measured in
|
||||
/// Excel: ring outer radii ≈0.83, 0.92 and 1.0 of the plot radius for series 1, 2 and 3). The hole
|
||||
/// size and first slice angle come from the chart; each ring is scaled to its own total.
|
||||
pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let nser = data.series.len().min(MAX_SERIES);
|
||||
if nser == 0 {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = (area.cx(), area.cy());
|
||||
let r = area.w.min(area.h) / 2.0 * 0.92;
|
||||
if r < 2.0 {
|
||||
return;
|
||||
}
|
||||
let hole = f32::from(chart.hole_size.clamp(10, 90)) / 100.0;
|
||||
let ri = r * hole;
|
||||
let thick = (r - ri) / nser as f32;
|
||||
// A ring thinner than a pixel would vanish. Rather than suppressing the whole chart when there
|
||||
// are more series than the annulus has pixels, keep the outer rings at one pixel each and drop
|
||||
// the innermost series that no longer fit: Excel's order makes the first series the innermost,
|
||||
// so the outermost rings are the ones worth keeping.
|
||||
let band = thick.max(1.0);
|
||||
let start = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
|
||||
// Only rings with a pixel of band are drawn, and the wedge budget is shared across them in
|
||||
// drawing order: the innermost ring (the first series) gets its slices first, and the outer
|
||||
// rings are capped instead of the series × category product growing without bound.
|
||||
let rings: Vec<(&SeriesData, f32, f32)> = data
|
||||
.series
|
||||
.iter()
|
||||
.take(nser)
|
||||
.enumerate()
|
||||
.filter_map(|(si, s)| {
|
||||
let outer = (r - (nser - 1 - si) as f32 * band).min(r);
|
||||
let inner = (r - (nser - si) as f32 * band).max(ri);
|
||||
(outer - inner >= 1.0).then_some((s, outer, inner))
|
||||
})
|
||||
.collect();
|
||||
let mut budget = MAX_WEDGES;
|
||||
let mut labels = Vec::new();
|
||||
for (k, (s, outer, inner)) in rings.iter().enumerate() {
|
||||
// The white separator is 1.5 units wide, wider than a one-pixel band: on a thin ring it
|
||||
// would paint over the fill of this ring and its neighbour, so such rings keep only fill.
|
||||
let stroke = if outer - inner > 1.0 { Some((WHITE, 1.5)) } else { None };
|
||||
let ring = Ring { cx, cy, outer: *outer, inner: *inner, stroke };
|
||||
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
|
||||
let cap = (budget / (rings.len() - k)).max(1);
|
||||
let slices = largest_slices(&vals, cap);
|
||||
let total: f64 = slices.iter().map(|(_, v)| *v).sum();
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let n = slices.len();
|
||||
budget = budget.saturating_sub(n);
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let mut a = start;
|
||||
for (j, v) in slices {
|
||||
let sweep = (v / total) as f32 * TAU;
|
||||
if sweep > 1e-4 {
|
||||
let fill = point_color(data, j);
|
||||
wedge(ctx, &ring, a, sweep, fill);
|
||||
if chart.data_labels && n <= MAX_LABELS {
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let mid = a + sweep / 2.0;
|
||||
let rr = (outer + inner) / 2.0;
|
||||
if ctx.tw(&t, LABEL_SIZE) <= (sweep * rr).max(outer - inner) && sweep * rr >= LABEL_SIZE {
|
||||
labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill)));
|
||||
}
|
||||
}
|
||||
}
|
||||
a += sweep;
|
||||
}
|
||||
}
|
||||
for (x, y, t, c) in labels {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- treemap
|
||||
|
||||
fn worst(row: &[f64], side: f64) -> f64 {
|
||||
let s: f64 = row.iter().sum();
|
||||
/// 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;
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -224,9 +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();
|
||||
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;
|
||||
@@ -234,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;
|
||||
@@ -244,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;
|
||||
@@ -256,15 +394,17 @@ 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;
|
||||
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
|
||||
let mut with = cur.clone();
|
||||
with.push(a);
|
||||
if row.is_empty() || worst(&with, side) <= worst(&cur, side) {
|
||||
row.push((j, 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);
|
||||
row.clear();
|
||||
(sum, mn, mx) = (0.0, f64::INFINITY, 0.0);
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
@@ -279,14 +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);
|
||||
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 = items.iter().find(|x| x.0 == j).map(|x| x.1).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).
|
||||
@@ -42,6 +43,8 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
return vec![];
|
||||
}
|
||||
match chart.kind {
|
||||
// Pie, doughnut (Excel's default `varyColors=1`, so every ring repeats the category colours
|
||||
// and the legend names categories rather than series), treemap and sunburst.
|
||||
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
|
||||
let n = first_len(data).min(MAX_CATS);
|
||||
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
|
||||
@@ -58,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,
|
||||
@@ -69,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));
|
||||
}
|
||||
@@ -81,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 }
|
||||
}
|
||||
|
||||
@@ -145,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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
-26
@@ -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).
|
||||
@@ -137,9 +188,38 @@ fn value_text(v: &Value) -> String {
|
||||
/// 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)));
|
||||
let texts: Vec<String> = vals
|
||||
.iter()
|
||||
@@ -159,8 +239,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
|
||||
})
|
||||
.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 Some(levels) = levels 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
|
||||
@@ -179,9 +258,10 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
|
||||
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
|
||||
/// that sheet), the way the evaluator looks names up.
|
||||
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
|
||||
match t.split_once('!') {
|
||||
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
None => wb.name(t, sheet).is_some(),
|
||||
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
|
||||
match gridcraft_core::split_sheet(t) {
|
||||
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
(None, _) => wb.name(t, sheet).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +280,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, _) => {
|
||||
@@ -215,9 +295,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
|
||||
@@ -227,37 +313,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,
|
||||
@@ -276,3 +368,69 @@ 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 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+423
-1
@@ -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] = [
|
||||
@@ -48,6 +49,8 @@ fn chart(kind: ChartKind) -> Chart {
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 0,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: Some("Quarter".into()),
|
||||
y_title: Some("Revenue".into()),
|
||||
source: None,
|
||||
@@ -149,6 +152,317 @@ fn every_kind_renders_sanely() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_draws_each_series_as_a_ring() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 25;
|
||||
c.first_slice_angle = 0;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B", "C"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("Inner", &[1.0, 2.0, 3.0], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Middle", &[3.0, 2.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
series("Outer", &[1.0, 1.0, 1.0], [89, 161, 79, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let wedges: Vec<(f32, f32, f32)> = prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { r_outer, r_inner, a0, .. } => Some((*r_outer, *r_inner, *a0)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(wedges.len(), 9, "three rings of three slices");
|
||||
// Excel draws the first series as the innermost ring; the hole is 25% of the radius.
|
||||
assert!(wedges[0].0 < wedges[3].0 && wedges[3].0 < wedges[6].0, "{wedges:?}");
|
||||
assert!(wedges[0].1 > 0.0, "hole: {wedges:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_with_many_series_still_draws_the_outer_rings() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 75;
|
||||
// The annulus is a few dozen pixels tall and there are far more series than pixels, so every
|
||||
// ring is thinner than 1 px. The chart used to draw nothing at all.
|
||||
let many = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
|
||||
};
|
||||
let single = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
// Two series, so the title fallback (a lone series names the chart) does not shrink the plot.
|
||||
series: vec![
|
||||
series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Other", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let (w, h) = (640.0, 400.0);
|
||||
let radii = |d: &ChartData| -> Vec<f32> {
|
||||
render(&c, d, w, h, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { r_outer, .. } => Some(*r_outer),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let outer = radii(&many);
|
||||
assert!(!outer.is_empty(), "a sub-pixel ring must not suppress the whole chart");
|
||||
let mut distinct = outer.clone();
|
||||
distinct.sort_by(f32::total_cmp);
|
||||
distinct.dedup();
|
||||
assert!(distinct.len() > 8, "only {} rings drawn", distinct.len());
|
||||
assert!(distinct.len() < 255, "too many rings for the pixels available: {}", distinct.len());
|
||||
// The outermost ring still reaches the plot radius the single-ring chart uses.
|
||||
let r = radii(&single).into_iter().fold(0.0f32, f32::max);
|
||||
let drawn = distinct.last().copied().unwrap_or(0.0);
|
||||
assert!((drawn - r).abs() < 0.01, "outermost ring {drawn} of {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_one_pixel_rings_skip_the_separator_stroke() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 75;
|
||||
// 255 series over a 46-pixel annulus: every drawn ring is one pixel tall, narrower than the
|
||||
// 1.5-pixel white separator, which would otherwise paint over both neighbouring rings.
|
||||
let thin = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
|
||||
};
|
||||
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thin, 640.0, 400.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { stroke, .. } => Some(*stroke),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!strokes.is_empty(), "the chart must still draw its rings");
|
||||
assert!(strokes.iter().all(Option::is_none), "a 1.5px stroke covers a 1px band: {strokes:?}");
|
||||
|
||||
// A ring with room for the separator keeps it.
|
||||
let thick = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("Inner", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Outer", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thick, 640.0, 400.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { stroke, .. } => Some(*stroke),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!strokes.is_empty());
|
||||
assert!(strokes.iter().all(Option::is_some), "wide rings keep their separators: {strokes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_wedge_budget_bounds_many_series_and_categories() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 75;
|
||||
c.first_slice_angle = 0;
|
||||
// 20,000 categories across 80 series would be hundreds of thousands of wedges: the per-series
|
||||
// limit does not bound the product. The first ten categories of every ring are the largest.
|
||||
let cats = 20_000usize;
|
||||
let values: Vec<f64> = (0..cats).map(|j| if j < 10 { 1000.0 } else { 1.0 }).collect();
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: (0..cats).map(|j| format!("C{j}")).collect(),
|
||||
series: (0..80)
|
||||
.map(|i| SeriesData { name: format!("S{i}"), values: values.iter().map(|v| Some(*v)).collect(), ..SeriesData::default() })
|
||||
.collect(),
|
||||
};
|
||||
let wedges: Vec<(f32, f32)> = render(&c, &d, 640.0, 400.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { r_outer, a0, a1, .. } => Some((*r_outer, *a1 - *a0)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!wedges.is_empty(), "the innermost ring must still be drawn");
|
||||
let mut radii: Vec<f32> = wedges.iter().map(|(r, _)| *r).collect();
|
||||
radii.sort_by(f32::total_cmp);
|
||||
radii.dedup();
|
||||
assert!(radii.len() > 1, "every drawable ring is capped, not dropped");
|
||||
// A slice crossing 12 o'clock is split into two primitives, so each ring may add one.
|
||||
let budget = crate::render::MAX_WEDGES + radii.len();
|
||||
assert!(wedges.len() <= budget, "{} wedges for {} rings", wedges.len(), radii.len());
|
||||
|
||||
// The first (innermost) ring keeps its largest slices and still fills the circle.
|
||||
let inner: Vec<f32> = wedges.iter().filter(|(r, _)| *r == radii[0]).map(|(_, a)| *a).collect();
|
||||
assert!(inner.len() < cats, "the ring was not capped: {}", inner.len());
|
||||
assert_eq!(inner.iter().filter(|a| **a > 0.1).count(), 10, "largest slices went missing");
|
||||
let cover: f32 = inner.iter().sum();
|
||||
assert!((cover - std::f32::consts::TAU).abs() < 0.01, "first ring covers {cover}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_first_slice_angle_rotates_the_start() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.first_slice_angle = 90;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)],
|
||||
};
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let first = prims.iter().find_map(|p| match p {
|
||||
Prim::Wedge { a0, .. } => Some(*a0),
|
||||
_ => None,
|
||||
});
|
||||
assert!(first.is_some_and(|a| (a - std::f32::consts::FRAC_PI_2).abs() < 0.01), "{first:?}");
|
||||
}
|
||||
|
||||
/// Every slice must occupy exactly its angular share, even when the first slice angle pushes the
|
||||
/// start past 12 o'clock (the split at TAU used to put whole slices over their neighbours).
|
||||
#[test]
|
||||
fn rotated_wedges_cover_each_slice_once_without_overlap() {
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
for kind in [ChartKind::Doughnut, ChartKind::Pie] {
|
||||
for angle in [180u16, 270] {
|
||||
let mut c = chart(kind);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.first_slice_angle = angle;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
|
||||
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![series("Only", &[1.0; 4], [20, 96, 130, 255], kind)],
|
||||
};
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let wedges: Vec<(f32, f32)> = prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { a0, a1, .. } => Some((*a0, *a1)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(wedges.len() >= 4, "{kind:?} at {angle}: {wedges:?}");
|
||||
for (a0, a1) in &wedges {
|
||||
assert!((0.0..TAU).contains(a0), "{kind:?} at {angle}: start {a0} outside [0, TAU)");
|
||||
assert!(*a1 > *a0 && *a1 <= TAU + 1e-4, "{kind:?} at {angle}: bad sweep {a0}..{a1}");
|
||||
}
|
||||
// Sample away from the boundaries: a correct four-slice chart covers every angle once.
|
||||
let steps = 1440;
|
||||
for k in 0..steps {
|
||||
let theta = (k as f32 + 0.5) * TAU / steps as f32;
|
||||
let cover = wedges.iter().filter(|(a0, a1)| theta >= *a0 && theta < *a1).count();
|
||||
assert_eq!(cover, 1, "{kind:?} at {angle}: {theta} covered {cover} times by {wedges:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice that crosses 12 o'clock is filled as two halves but outlined once: stroking each half
|
||||
/// separately ran the white separator along the shared radial edge, straight through the slice.
|
||||
#[test]
|
||||
fn slice_crossing_twelve_has_no_internal_seam() {
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
let mut c = chart(ChartKind::Pie);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.first_slice_angle = 90;
|
||||
let a = [230, 126, 34, 255];
|
||||
let b = [39, 174, 96, 255];
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![a, b],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![series("Only", &[1.0, 1.0], a, ChartKind::Pie)],
|
||||
};
|
||||
let (w, h) = (400u32, 400u32);
|
||||
let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure);
|
||||
// The second slice starts at 270° and wraps past 0°, so it is drawn as two unstroked halves.
|
||||
let mut halves = 0;
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
let mut r = 0.0;
|
||||
for p in &prims {
|
||||
if let Prim::Wedge { cx: x, cy: y, r_outer, a0, a1, fill, stroke, .. } = p
|
||||
&& *fill == b
|
||||
{
|
||||
assert!(stroke.is_none(), "a split half must not stroke its shared edge: {p:?}");
|
||||
assert!(*a0 == 0.0 || *a1 == TAU, "the crossing slice is split at 12 o'clock: {p:?}");
|
||||
halves += 1;
|
||||
(cx, cy, r) = (*x, *y, *r_outer);
|
||||
}
|
||||
}
|
||||
assert_eq!(halves, 2, "one slice crosses 12 o'clock");
|
||||
// Exactly one outline covers the whole crossing slice, drawn as line primitives.
|
||||
assert_eq!(prims.iter().filter(|p| matches!(p, Prim::Line { color, .. } if *color == [255, 255, 255, 255])).count(), 1, "{prims:?}");
|
||||
// No white pixel inside the slice along 12 o'clock, where the shared edge used to be stroked.
|
||||
let img = rasterize(&prims, w, h, [255, 255, 255, 255]);
|
||||
for t in [0.3f32, 0.5, 0.7] {
|
||||
assert_eq!(pixel(&img, w, cx as u32, (cy - r * t) as u32), b, "white seam at {t} of the slice");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_legend_names_categories_and_matches_every_ring() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::Right;
|
||||
c.first_slice_angle = 180;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
|
||||
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("Outer", &[1.0; 4], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Inner", &[1.0; 4], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let entries = legend_entries(&c, &d);
|
||||
assert_eq!(entries.iter().map(|(name, _, _)| name.as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D"]);
|
||||
|
||||
let start = f32::from(c.first_slice_angle) * std::f32::consts::TAU / 360.0;
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let mut rings = Vec::new();
|
||||
for p in &prims {
|
||||
let Prim::Wedge { r_outer, a0, a1, fill, .. } = p else { continue };
|
||||
rings.push(*r_outer);
|
||||
let rel = ((a0 + a1) / 2.0 - start).rem_euclid(std::f32::consts::TAU);
|
||||
let cat = ((rel / (std::f32::consts::TAU / 4.0)) as usize).min(3);
|
||||
assert_eq!(*fill, entries[cat].1, "ring wedge at {a0}..{a1} does not match legend entry {}", entries[cat].0);
|
||||
}
|
||||
rings.sort_by(f32::total_cmp);
|
||||
rings.dedup();
|
||||
assert_eq!(rings.len(), 2, "two series must draw two rings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_counts() {
|
||||
let d = sample(ChartKind::ColumnClustered);
|
||||
@@ -461,7 +775,11 @@ fn pie_starts_at_twelve_clockwise() {
|
||||
assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4));
|
||||
assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3);
|
||||
assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4])));
|
||||
let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure);
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.hole_size = 50;
|
||||
let mut d = sample(ChartKind::Doughnut);
|
||||
d.series.truncate(1);
|
||||
let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure);
|
||||
let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3));
|
||||
assert!(ok);
|
||||
}
|
||||
@@ -1101,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 {
|
||||
@@ -1258,3 +1608,75 @@ fn box_whisker_draws_each_series_quartile_method() {
|
||||
// The boxes span 3.5 and 2.5 data units on the same value axis.
|
||||
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() {
|
||||
// #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series
|
||||
// was named by the literal text.
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "Bob's".into();
|
||||
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
|
||||
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
|
||||
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Title".into(),
|
||||
scope: Some(0),
|
||||
formula: "'Bob''s'!$A$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("'Bob''s'!Title".into()),
|
||||
categories: None,
|
||||
values: "'Bob''s'!$A$2:$A$3".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
|
||||
// `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at
|
||||
// the last one, not the first.
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "a!b".into();
|
||||
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
|
||||
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
|
||||
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Title".into(),
|
||||
scope: Some(0),
|
||||
formula: "'a!b'!$A$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("'a!b'!Title".into()),
|
||||
categories: None,
|
||||
values: "'a!b'!$A$2:$A$3".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,10 +296,40 @@ pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
|
||||
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
|
||||
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
|
||||
/// quoted state, so a doubled one toggles it twice.
|
||||
pub fn split_areas(reference: &str) -> Vec<&str> {
|
||||
let mut areas = Vec::new();
|
||||
let (mut start, mut quoted) = (0, false);
|
||||
for (i, c) in reference.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
',' if !quoted => {
|
||||
areas.push(reference.get(start..i).unwrap_or_default());
|
||||
start = i.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
areas.push(reference.get(start..).unwrap_or_default());
|
||||
areas
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
|
||||
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
|
||||
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
|
||||
assert_eq!(split_areas(""), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
|
||||
assert!(RangeRef::parse("+1:+3").is_none());
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod value;
|
||||
|
||||
pub use addr::{
|
||||
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
|
||||
split_sheet, unquote_sheet,
|
||||
split_areas, split_sheet, unquote_sheet,
|
||||
};
|
||||
pub use date::DateSystem;
|
||||
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
|
||||
|
||||
@@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
|
||||
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
|
||||
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
|
||||
// Developer (Insert › Controls in GridCraft)
|
||||
("insert.button", "Developer", "Controls", "Button (Form Control)"),
|
||||
("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"),
|
||||
("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"),
|
||||
("control.format", "Developer", "Controls", "Properties"),
|
||||
// Table Design
|
||||
("table.rename", "Table Design", "Properties", "Table Name"),
|
||||
("table.resize", "Table Design", "Properties", "Resize Table"),
|
||||
|
||||
@@ -17,11 +17,13 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
|
||||
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
|
||||
const MAX_HTML_CELLS: usize = 1_000_000;
|
||||
|
||||
/// One table cell: its visible text and the number the source recorded for it, if any.
|
||||
/// One table cell: its visible text, the number the source recorded for it, if any, and whether
|
||||
/// it is a header cell (`<th>`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct HtmlCell {
|
||||
pub text: String,
|
||||
pub num: Option<f64>,
|
||||
pub header: bool,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
|
||||
@@ -88,7 +90,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<V
|
||||
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let colspan = span(attrs, "colspan", MAX_SPAN);
|
||||
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan });
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
|
||||
("br", _) => {
|
||||
@@ -376,18 +378,18 @@ mod tests {
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel, 2, 3);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() });
|
||||
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
|
||||
assert_eq!(rows[1][0].text, "2.50 & up");
|
||||
assert_eq!(rows[1][2].text, "x");
|
||||
|
||||
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
|
||||
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]);
|
||||
|
||||
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -434,7 +436,7 @@ mod tests {
|
||||
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
|
||||
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
|
||||
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() });
|
||||
|
||||
// Bounded by the pasted text's shape, the same markup yields just that shape.
|
||||
let rows = table_cells(&html, 3, 2);
|
||||
|
||||
+131
-6
@@ -2,6 +2,7 @@
|
||||
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
|
||||
|
||||
pub mod analysis;
|
||||
pub mod controls;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
@@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(format::specs());
|
||||
v.extend(sheet::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(controls::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(data::specs());
|
||||
v.extend(formulas::specs());
|
||||
@@ -404,7 +406,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
edits: Vec::new(),
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
let Ctx { mut wb, mut changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
// Equations whose source changed (or that arrived without a layout, as from a paste) are
|
||||
// laid out here, so painting only ever reads the cache.
|
||||
wb.refresh_equations();
|
||||
@@ -414,6 +416,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if !structural {
|
||||
check_arrays(&d.wb, &wb)?;
|
||||
}
|
||||
if wb.calc.precision_as_displayed {
|
||||
round_edited_as_displayed(&d.wb, &mut wb, &mut changed, structural);
|
||||
}
|
||||
if let Some(log) = d.touched.as_mut() {
|
||||
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
|
||||
if let Some(sh) = wb.sheet(*si) {
|
||||
@@ -435,6 +440,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
} else if !changed.is_empty() {
|
||||
d.calc.cells_changed(&mut wb, &changed);
|
||||
}
|
||||
controls::sync_linked_values(&mut wb);
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + recalc {:?}", since(t0));
|
||||
}
|
||||
@@ -451,6 +457,117 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": a number typed, pasted or reformatted is stored as its format
|
||||
/// shows it (Excel rounds a constant when it is entered and when its cell's format changes,
|
||||
/// observed through COM). Only cells the edit touched are visited, so this costs nothing for
|
||||
/// the rest of the workbook; formula cells whose format changed are recalculated, and the
|
||||
/// recalculation rounds them. A structural edit recalculates everything anyway, so it only
|
||||
/// rounds its constants (a sheet the edit created, for one, holds written numbers).
|
||||
fn round_edited_as_displayed(old: &Workbook, wb: &mut Workbook, changed: &mut Vec<gridcraft_calc::Key>, structural: bool) {
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(after) = wb.sheets.get(si) else { continue };
|
||||
// Sheets are matched by name. A sheet the edit created has no earlier version, so all of
|
||||
// its cells are new.
|
||||
let before = old.sheet_index(&after.name).and_then(|i| old.sheets.get(i));
|
||||
if before.is_some_and(|b| std::sync::Arc::ptr_eq(b, after)) {
|
||||
continue;
|
||||
}
|
||||
let diffs: Vec<CellRef> = match before {
|
||||
Some(before) => before.cells.diff(&after.cells),
|
||||
None => after.cells.iter().map(|(c, _)| c).collect(),
|
||||
};
|
||||
let mut anchors = std::collections::HashSet::new();
|
||||
for &c in &diffs {
|
||||
if !structural && after.cell(c).is_some_and(|cell| cell.formula.is_some()) {
|
||||
changed.push((si, c));
|
||||
anchors.insert(c);
|
||||
}
|
||||
}
|
||||
let updates = displayed_updates(&mut prec, wb, si, diffs.iter().copied());
|
||||
// A spill destination is no stored cell: reformatting it (or its row or column) leaves
|
||||
// no change in the store, so its anchor is recalculated to round the new element.
|
||||
if let (Some(before), false) = (before, structural) {
|
||||
for (&anchor, range) in &after.spill_ranges {
|
||||
if !anchors.contains(&anchor)
|
||||
&& (diffs.iter().any(|d| range.contains(*d))
|
||||
|| line_styles_differ(&before.rows, &after.rows, range.start.row, range.end.row)
|
||||
|| line_styles_differ(&before.cols, &after.cols, range.start.col, range.end.col))
|
||||
{
|
||||
changed.push((si, anchor));
|
||||
}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, r) in updates {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.value = gridcraft_core::Value::Number(r);
|
||||
if !structural {
|
||||
changed.push((si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any row or column style in `lo..=hi` differs between two sheets' line maps.
|
||||
fn line_styles_differ(
|
||||
before: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
|
||||
after: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
) -> bool {
|
||||
let style = |m: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>, k: u32| m.get(&k).and_then(|l| l.style);
|
||||
after.range(lo..=hi).any(|(k, l)| l.style != style(before, *k)) || before.range(lo..=hi).any(|(k, l)| l.style != style(after, *k))
|
||||
}
|
||||
|
||||
/// Turning "Set precision as displayed" on rounds every number constant in the workbook to its
|
||||
/// format, permanently (Excel warns "Data will permanently lose accuracy"). Formulas are rounded
|
||||
/// by the recalculation that follows.
|
||||
pub(crate) fn round_all_constants_as_displayed(wb: &mut Workbook) {
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let updates = displayed_updates(&mut prec, wb, si, sh.cells.iter().map(|(c, _)| c));
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, r) in updates {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.value = gridcraft_core::Value::Number(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The constants among `cells` on sheet `si` whose value the option changes, with their rounded
|
||||
/// values. A locked constant on a protected sheet keeps its value: protection keeps its content,
|
||||
/// and rounding it by its format would change the content that the sheet protects.
|
||||
fn displayed_updates(
|
||||
prec: &mut gridcraft_calc::DisplayedPrecision,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
cells: impl Iterator<Item = CellRef>,
|
||||
) -> Vec<(CellRef, f64)> {
|
||||
let Some(sh) = wb.sheet(si) else { return Vec::new() };
|
||||
cells
|
||||
.filter(|&c| !(sh.is_protected() && wb.styles.get(sh.style_id(c)).protection.locked))
|
||||
.filter_map(|c| {
|
||||
let cell = sh.cell(c)?;
|
||||
match cell.value {
|
||||
gridcraft_core::Value::Number(n) if cell.formula.is_none() => {
|
||||
let r = prec.round(wb, si, c, n);
|
||||
(r.to_bits() != n.to_bits()).then_some((c, r))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
|
||||
/// that kept a cell's value, so a shift is refused then.
|
||||
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
@@ -459,8 +576,11 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
|
||||
/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
|
||||
/// that moves an unlocked cell with equal content onto a locked one changes no content but would
|
||||
/// leave the cell editable, so the effective lock (row and column formats included) is compared
|
||||
/// as well.
|
||||
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
@@ -471,7 +591,8 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
if !same_content(sh.cell(c), after.cell(c))
|
||||
let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
|
||||
if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
|
||||
&& old.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, unlocked)
|
||||
{
|
||||
@@ -499,10 +620,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick:
|
||||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||||
let mut needs_password = None;
|
||||
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
|
||||
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||||
// A range limited to Windows users or groups (a security descriptor) can't be checked
|
||||
// here, so without a password it stays locked rather than opening to every editor.
|
||||
if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||||
return None;
|
||||
}
|
||||
needs_password.get_or_insert(r.title.as_str());
|
||||
if r.password.is_some() {
|
||||
needs_password.get_or_insert(r.title.as_str());
|
||||
}
|
||||
}
|
||||
Some(match needs_password {
|
||||
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin
|
||||
//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell.
|
||||
//!
|
||||
//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes
|
||||
//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step;
|
||||
//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs
|
||||
//! its action through the macro policy with the document's trust, so a button from a file someone
|
||||
//! else made asks before it runs anything.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{
|
||||
Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
|
||||
has_doc, split_sheet, stash, str_param, u32_param,
|
||||
};
|
||||
use crate::macros::policy::{Policy, macro_policy};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.button",
|
||||
"Button",
|
||||
["Insert", "Controls"],
|
||||
None,
|
||||
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}",
|
||||
has_doc,
|
||||
insert_button
|
||||
),
|
||||
cmd!(
|
||||
"insert.slider",
|
||||
"Slider",
|
||||
["Insert", "Controls"],
|
||||
None,
|
||||
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}",
|
||||
has_doc,
|
||||
|s, p| insert_number_control(s, p, ControlKind::Slider)
|
||||
),
|
||||
cmd!(
|
||||
"insert.spinButton",
|
||||
"Spin Button",
|
||||
["Insert", "Controls"],
|
||||
None,
|
||||
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}",
|
||||
has_doc,
|
||||
|s, p| insert_number_control(s, p, ControlKind::Spin)
|
||||
),
|
||||
cmd!(
|
||||
"control.format",
|
||||
"Format Control",
|
||||
[],
|
||||
None,
|
||||
"{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
|
||||
has_doc,
|
||||
format
|
||||
),
|
||||
cmd!(
|
||||
"control.assign",
|
||||
"Assign Action",
|
||||
[],
|
||||
None,
|
||||
"{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
|
||||
has_doc,
|
||||
assign
|
||||
),
|
||||
cmd!(
|
||||
"control.setValue",
|
||||
"Change Control Value",
|
||||
[],
|
||||
None,
|
||||
"{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
|
||||
has_doc,
|
||||
set_value
|
||||
),
|
||||
cmd!(
|
||||
"control.press",
|
||||
"Press Button",
|
||||
[],
|
||||
None,
|
||||
"{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned",
|
||||
has_doc,
|
||||
press
|
||||
),
|
||||
cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list),
|
||||
]
|
||||
}
|
||||
|
||||
/// The longest caption or name a control takes.
|
||||
const MAX_TEXT: usize = 1_024;
|
||||
|
||||
fn control_json(c: &FormControl) -> Json {
|
||||
let mut o = json!({
|
||||
"id": c.id,
|
||||
"kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
|
||||
"name": c.display_name(),
|
||||
"at": c.anchor.cell.a1(),
|
||||
"width": c.anchor.width,
|
||||
"height": c.anchor.height,
|
||||
"print": c.print,
|
||||
"locked": c.locked,
|
||||
});
|
||||
if c.kind == ControlKind::Button {
|
||||
o["text"] = json!(c.text);
|
||||
if let Some(a) = &c.action {
|
||||
o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
|
||||
}
|
||||
} else {
|
||||
o["value"] = json!(c.value);
|
||||
o["min"] = json!(c.min);
|
||||
o["max"] = json!(c.max);
|
||||
o["step"] = json!(c.step);
|
||||
if c.kind == ControlKind::Slider {
|
||||
o["page"] = json!(c.page);
|
||||
o["horizontal"] = json!(c.horizontal);
|
||||
}
|
||||
}
|
||||
if let Some(l) = &c.link {
|
||||
o["link"] = json!(l);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
|
||||
}
|
||||
|
||||
/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything
|
||||
/// else is refused with a message that names the parameter.
|
||||
fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
|
||||
let v = match p.get(key) {
|
||||
None | Some(Json::Null) => return Ok(None),
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
|
||||
Some(_) => None,
|
||||
};
|
||||
match v {
|
||||
Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
|
||||
Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it
|
||||
/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`).
|
||||
pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
|
||||
let text = link.trim().trim_start_matches('=');
|
||||
let (sheet, body) = split_sheet(text);
|
||||
let si = match sheet {
|
||||
Some(n) => wb.sheet_index(&n)?,
|
||||
None => host,
|
||||
};
|
||||
wb.sheet(si)?;
|
||||
Some((si, CellRef::parse(body.trim())?))
|
||||
}
|
||||
|
||||
/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere.
|
||||
/// An empty text removes the link.
|
||||
fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
|
||||
let text = match p.get("link") {
|
||||
None => return Ok(None),
|
||||
Some(Json::Null) => return Ok(Some(None)),
|
||||
Some(Json::String(t)) => t.trim(),
|
||||
Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let (si, c) = link_target(wb, host, text)
|
||||
.ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
|
||||
let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
|
||||
Ok(Some(Some(if si == host {
|
||||
abs
|
||||
} else {
|
||||
let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
format!("{}!{abs}", gridcraft_formula::quote_sheet(&name))
|
||||
})))
|
||||
}
|
||||
|
||||
/// The anchor of a new control: `at` or `cell` (default the active cell), with its size.
|
||||
fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
|
||||
let cell = match cell_param(s, p, "at")? {
|
||||
Some(c) => c,
|
||||
None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
|
||||
};
|
||||
let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32);
|
||||
let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32);
|
||||
Ok(Anchor {
|
||||
cell,
|
||||
dx: off("dx"),
|
||||
dy: off("dy"),
|
||||
width: dim("width", size.0),
|
||||
height: dim("height", size.1),
|
||||
mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "insert.button";
|
||||
let anchor = new_anchor(s, p, (96.0, 28.0))?;
|
||||
let action = action_param(s, CMD, p)?;
|
||||
let d = s.doc()?;
|
||||
let id = d.wb.next_object_id();
|
||||
let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
|
||||
let sheet = d.wb.active_sheet();
|
||||
let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.controls.push(control);
|
||||
Ok(json!({"control": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
|
||||
let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
|
||||
let horizontal = bool_param(p, "horizontal").unwrap_or(true);
|
||||
let size = match kind {
|
||||
ControlKind::Slider if horizontal => (144.0, 18.0),
|
||||
ControlKind::Slider => (18.0, 144.0),
|
||||
_ => (18.0, 36.0),
|
||||
};
|
||||
let anchor = new_anchor(s, p, size)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let id = d.wb.next_object_id();
|
||||
let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
|
||||
numbers(cmd, p, &mut c)?;
|
||||
if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
|
||||
c.link = link;
|
||||
}
|
||||
let given = whole(cmd, p, "value")?;
|
||||
// Without a value, a control starts at the number its linked cell holds (or its minimum).
|
||||
let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
|
||||
c.value = c.snap(start);
|
||||
let value = c.value;
|
||||
let link = c.link.clone();
|
||||
s.grouped(&format!("Insert {}", kind.label()), |s| {
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.controls.push(c);
|
||||
Ok(())
|
||||
})?;
|
||||
if given.is_some() {
|
||||
write_link(s, sheet, link.as_deref(), value)?;
|
||||
}
|
||||
Ok(json!({"control": id}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result.
|
||||
fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
|
||||
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
|
||||
if let Some(v) = whole(cmd, p, key)? {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
// A new slider's page follows a larger step unless it was given.
|
||||
if p.get("page").is_none() && c.page < c.step {
|
||||
c.page = c.step;
|
||||
}
|
||||
c.check_numbers().map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
/// The number the control's linked cell holds, if it holds one.
|
||||
fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
|
||||
let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
|
||||
wb.sheet(si)?.cell(cell)?.value.as_f64()
|
||||
}
|
||||
|
||||
/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell
|
||||
/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and
|
||||
/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell
|
||||
/// of a protected sheet; a real change replaces a formula with the constant, as in Excel).
|
||||
fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
|
||||
let Some(link) = link else { return Ok(()) };
|
||||
let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
|
||||
let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
|
||||
if holds == Some(value as f64) {
|
||||
return Ok(());
|
||||
}
|
||||
s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy).
|
||||
fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
|
||||
let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
|
||||
Ok((sheet, i, c))
|
||||
}
|
||||
|
||||
/// Drawing objects on a protected sheet can't be changed while they are locked.
|
||||
/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`,
|
||||
/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the
|
||||
/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass.
|
||||
pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
|
||||
if kind != ObjKind::Control {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
|
||||
if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
|
||||
if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.format";
|
||||
let (sheet, i, mut c) = find(s, CMD, p)?;
|
||||
check_unlocked(s, sheet, &c)?;
|
||||
if let Some(t) = text_param(CMD, p, "text")? {
|
||||
c.text = t;
|
||||
}
|
||||
if let Some(n) = text_param(CMD, p, "name")? {
|
||||
c.name = n.trim().to_string();
|
||||
}
|
||||
for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
|
||||
if let Some(v) = bool_param(p, key) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
let mut given = None;
|
||||
if c.kind != ControlKind::Button {
|
||||
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
|
||||
if let Some(v) = whole(CMD, p, key)? {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
c.check_numbers().map_err(|e| bad(CMD, e))?;
|
||||
given = whole(CMD, p, "value")?;
|
||||
}
|
||||
if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
|
||||
c.link = link;
|
||||
}
|
||||
// New bounds pull the value in; a given value is snapped to them.
|
||||
c.value = c.snap(given.map_or(c.value as f64, f64::from));
|
||||
let number = c.kind != ControlKind::Button;
|
||||
let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
|
||||
let (value, link) = (c.value, c.link.clone());
|
||||
s.grouped("Format Control", |s| {
|
||||
edit(s, |cx| {
|
||||
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if write {
|
||||
write_link(s, sheet, link.as_deref(), value)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The reply is the control as committed: a new link to a cell holding another number has
|
||||
// moved its value there.
|
||||
let (_, _, c) = find(s, CMD, p)?;
|
||||
Ok(control_json(&c))
|
||||
}
|
||||
|
||||
/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show
|
||||
/// what its command would open, so a web page is refused when the action is assigned or comes in
|
||||
/// from a file. A command that turns out to open a page or a dialog when it runs is refused
|
||||
/// then, by [`press`].
|
||||
const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
|
||||
|
||||
/// The macro policy for a button's command, with the button's own refusals first.
|
||||
fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy {
|
||||
match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
|
||||
Some((_, why)) => Policy::Deny((*why).into()),
|
||||
None => macro_policy(id, params, trust),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the
|
||||
/// person, and says why the press is refused when one would open a dialog or a web page, which
|
||||
/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too.
|
||||
pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
|
||||
use crate::UiRequest;
|
||||
let mut blocked = None;
|
||||
let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
|
||||
for q in asked {
|
||||
match q {
|
||||
UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
|
||||
UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
|
||||
UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
|
||||
m @ UiRequest::Message(_) => s.ui_requests.push(m),
|
||||
}
|
||||
}
|
||||
blocked
|
||||
}
|
||||
|
||||
/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed
|
||||
/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such
|
||||
/// an assignment is refused when it is made and dropped to the selection when a file brings one
|
||||
/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given.
|
||||
fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
|
||||
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
|
||||
}
|
||||
|
||||
/// A button action from `macro` (with `input` and `output`) or `command` (with `params`).
|
||||
fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
|
||||
if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
if name.chars().count() > MAX_MACRO_NAME {
|
||||
return Err(bad(cmd, "`macro` is not a macro name"));
|
||||
}
|
||||
let input = match str_param(p, "input").map(str::trim) {
|
||||
None | Some("") => MacroInput::Selection,
|
||||
Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
|
||||
Some(t) => {
|
||||
let (_, body) = split_sheet(t);
|
||||
if gridcraft_core::RangeRef::parse(body).is_none() {
|
||||
return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
|
||||
}
|
||||
if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
|
||||
return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
|
||||
}
|
||||
MacroInput::Fixed(t.to_string())
|
||||
}
|
||||
};
|
||||
let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
|
||||
if let Some(o) = &output
|
||||
&& gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
|
||||
}
|
||||
return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
|
||||
}
|
||||
if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
if find_command(id).is_none() {
|
||||
return Err(bad(cmd, format!("there is no command `{id}`")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
if !params.is_object() {
|
||||
return Err(bad(cmd, "`params` must be an object"));
|
||||
}
|
||||
let trust = s.doc()?.trust;
|
||||
if let Policy::Deny(why) = button_policy(id, ¶ms, trust) {
|
||||
return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
|
||||
}
|
||||
return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn assign(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.assign";
|
||||
let (sheet, i, mut c) = find(s, CMD, p)?;
|
||||
check_unlocked(s, sheet, &c)?;
|
||||
if c.kind != ControlKind::Button {
|
||||
return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
|
||||
}
|
||||
let action = action_param(s, CMD, p)?;
|
||||
if action.is_none() && bool_param(p, "clear") != Some(true) {
|
||||
return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
|
||||
}
|
||||
c.action = action;
|
||||
let out = control_json(&c);
|
||||
edit(s, |cx| {
|
||||
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.setValue";
|
||||
let (sheet, i, c) = find(s, CMD, p)?;
|
||||
if c.kind == ControlKind::Button {
|
||||
return Err(bad(CMD, "a button has no value; use control.press"));
|
||||
}
|
||||
let v = match p.get("value") {
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| bad(CMD, "`value` must be a number"))?;
|
||||
let value = c.snap(v);
|
||||
let label = format!("Change {}", c.kind.label());
|
||||
s.grouped(&label, |s| {
|
||||
if c.value != value {
|
||||
edit(s, |cx| {
|
||||
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
|
||||
slot.value = value;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
write_link(s, sheet, c.link.as_deref(), value)?;
|
||||
Ok(json!({"value": value}))
|
||||
})
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.press";
|
||||
let (_, _, c) = find(s, CMD, p)?;
|
||||
if c.kind != ControlKind::Button {
|
||||
return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
|
||||
}
|
||||
let name = c.display_name();
|
||||
match c.action {
|
||||
None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
|
||||
Some(ControlAction::Macro { name: mac, input, output }) => {
|
||||
let mut params = json!({"name": mac});
|
||||
if let MacroInput::Fixed(r) = input {
|
||||
params["inputs"] = json!(r);
|
||||
}
|
||||
if let Some(o) = output {
|
||||
params["output"] = json!(o);
|
||||
}
|
||||
for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
params[key] = v.clone();
|
||||
}
|
||||
}
|
||||
// The macro runner gates every step with the document's trust.
|
||||
crate::macros::run::run(s, &mac, ¶ms)
|
||||
}
|
||||
Some(ControlAction::Command { id, params }) => {
|
||||
let d = s.doc()?;
|
||||
let (doc, trust) = (d.uid, d.trust);
|
||||
let policy = button_policy(&id, ¶ms, trust);
|
||||
let file_command = matches!(policy, Policy::NeedsPermission(_));
|
||||
let why = match policy {
|
||||
Policy::Allow => None,
|
||||
Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
|
||||
Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
|
||||
};
|
||||
if let Some(why) = why {
|
||||
let key = (doc, approval_key(&id, ¶ms));
|
||||
if !s.macro_approvals.contains(&key) {
|
||||
if bool_param(p, "approve") != Some(true) {
|
||||
return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(¶ms))));
|
||||
}
|
||||
s.macro_approvals.insert(key);
|
||||
}
|
||||
}
|
||||
// As a macro step: only an approved file command reaches the file system, so a
|
||||
// command that reads or writes a file under another parameter name can't.
|
||||
let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter);
|
||||
s.grouped(&name, |s| {
|
||||
let queued = s.ui_requests.len();
|
||||
let r = s.execute(&id, params.clone());
|
||||
// As a macro step: a command that would open a dialog or a web page is refused
|
||||
// and its group rolled back; messages for the person stay queued.
|
||||
let blocked = take_requests(s, queued, &name);
|
||||
let r = r?;
|
||||
match blocked {
|
||||
Some(why) => Err(EngineError::Other(why)),
|
||||
None => Ok(r),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a button's command names in its `path`, as ` (path)` for its approval question, so
|
||||
/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one
|
||||
/// is shortened in the middle.
|
||||
fn path_note(params: &Json) -> String {
|
||||
let text = match params.get("path") {
|
||||
None | Some(Json::Null) => return String::new(),
|
||||
Some(Json::String(t)) => t.clone(),
|
||||
Some(other) => other.to_string(),
|
||||
};
|
||||
let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let n = text.chars().count();
|
||||
let shown = if n > 200 {
|
||||
let head: String = text.chars().take(100).collect();
|
||||
let tail: String = text.chars().skip(n - 99).collect();
|
||||
format!("{head}…{tail}")
|
||||
} else {
|
||||
text
|
||||
};
|
||||
format!(" ({shown})")
|
||||
}
|
||||
|
||||
/// The approval key of a button's command: its id and parameters, with a prefix that keeps it
|
||||
/// apart from a macro's fingerprint (canonical JSON, which starts with `{`).
|
||||
fn approval_key(id: &str, params: &Json) -> String {
|
||||
format!("control-command\u{0}{id}\u{0}{params}")
|
||||
}
|
||||
|
||||
/// After an edit: each slider and spin button whose linked cell holds a number takes that number
|
||||
/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control
|
||||
/// are left untouched.
|
||||
pub(crate) fn sync_linked_values(wb: &mut Workbook) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let updates: Vec<(usize, u32)> = match wb.sheet(si) {
|
||||
Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
|
||||
.controls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.kind != ControlKind::Button)
|
||||
.filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
|
||||
.collect(),
|
||||
_ => continue,
|
||||
};
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(sh) = wb.sheet_mut(si) {
|
||||
for (i, v) in updates {
|
||||
if let Some(c) = sh.controls.get_mut(i) {
|
||||
c.value = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- xlsx
|
||||
|
||||
/// The hidden-data key of the button actions xlsx has no place for.
|
||||
pub(crate) const ACTIONS_KEY: &str = "control_actions";
|
||||
|
||||
/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`).
|
||||
fn plain(a: &ControlAction) -> bool {
|
||||
matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
|
||||
}
|
||||
|
||||
/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs,
|
||||
/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with
|
||||
/// the button's name and anchor cell to check it is still the same button when the file comes
|
||||
/// back. `None` when there is nothing to store.
|
||||
pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
|
||||
let mut items = Vec::new();
|
||||
for sh in &wb.sheets {
|
||||
for (i, c) in sh.controls.iter().enumerate() {
|
||||
if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
|
||||
items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let stale = stash::get(wb, ACTIONS_KEY).is_some();
|
||||
if items.is_empty() && !stale {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = wb.clone();
|
||||
stash::remove(&mut out, ACTIONS_KEY);
|
||||
if !items.is_empty() {
|
||||
stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes
|
||||
/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the
|
||||
/// button's name and anchor cell, the button at the stored place must have that name and anchor
|
||||
/// cell (another application may have added, removed or reordered controls), and it must not
|
||||
/// have been given another macro since (a stored macro action comes back only on a button that
|
||||
/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed
|
||||
/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]).
|
||||
pub(crate) fn load_action_names(wb: &mut Workbook) {
|
||||
let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
|
||||
stash::remove(wb, ACTIONS_KEY);
|
||||
for item in items.as_array().into_iter().flatten().take(100_000) {
|
||||
let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
|
||||
let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
|
||||
let valid = match &action {
|
||||
ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
|
||||
ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
|
||||
};
|
||||
let Some(si) = wb.sheet_index(sheet) else { continue };
|
||||
let Ok(index) = usize::try_from(index) else { continue };
|
||||
let text = |k: &str| item.get(k).and_then(Json::as_str);
|
||||
let same = |c: &FormControl| {
|
||||
c.kind == ControlKind::Button
|
||||
&& text("name").is_some_and(|n| n == c.display_name())
|
||||
&& text("at").is_some_and(|a| a == c.anchor.cell.a1())
|
||||
&& match (&c.action, &action) {
|
||||
(None, ControlAction::Command { .. }) => true,
|
||||
(Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
|
||||
continue;
|
||||
}
|
||||
let action = match action {
|
||||
// The macro takes no inputs now: the stored range would fail every press, so the
|
||||
// button runs the macro on the selection, as the file itself assigns it.
|
||||
ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
|
||||
ControlAction::Macro { name, input: MacroInput::Selection, output }
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
|
||||
c.action = Some(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
|
||||
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(a) = sh.autofilter.as_mut() {
|
||||
@@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
edit(s, |cx| {
|
||||
apply_filter(&mut cx.wb, sheet);
|
||||
cx.structural = true;
|
||||
|
||||
@@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis.
|
||||
fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> {
|
||||
if rows {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)
|
||||
} else {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
// The selection belongs to the active sheet, so another sheet needs its lines.
|
||||
@@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
|
||||
}
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
refuse_unless_outline_allowed(s, si, rows)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
@@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
|
||||
pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?;
|
||||
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
@@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
|
||||
});
|
||||
}
|
||||
let active = s.doc()?.wb.active_sheet();
|
||||
for (n, is_rows) in [(rows, true), (cols, false)] {
|
||||
if n.is_some() {
|
||||
refuse_unless_outline_allowed(s, active, is_rows)?;
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
|
||||
@@ -18,21 +18,71 @@ fn op_param(s: Option<&str>) -> CfOperator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` minus `cut`, as up to four non-overlapping rectangles.
|
||||
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
let Some(i) = r.intersection(cut) else { return vec![r] };
|
||||
let mut out = Vec::new();
|
||||
if i.start.row > r.start.row {
|
||||
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
|
||||
/// Removes a set of cuts from one validation range in a row sweep, updating the result once
|
||||
/// instead of fragmenting it again for every cut.
|
||||
fn subtract_ranges(r: RangeRef, cuts: &[RangeRef]) -> Vec<RangeRef> {
|
||||
let mut events = std::collections::BTreeMap::<u64, Vec<((u32, u32), bool)>>::new();
|
||||
for cut in cuts {
|
||||
let Some(i) = r.intersection(cut) else { continue };
|
||||
let columns = (i.start.col, i.end.col);
|
||||
events.entry(u64::from(i.start.row)).or_default().push((columns, true));
|
||||
events.entry(u64::from(i.end.row) + 1).or_default().push((columns, false));
|
||||
}
|
||||
if i.end.row < r.end.row {
|
||||
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
|
||||
if events.is_empty() {
|
||||
return vec![r];
|
||||
}
|
||||
if i.start.col > r.start.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
|
||||
}
|
||||
if i.end.col < r.end.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
|
||||
events.entry(u64::from(r.end.row) + 1).or_default();
|
||||
|
||||
let mut active = std::collections::BTreeMap::<(u32, u32), usize>::new();
|
||||
let mut out: Vec<RangeRef> = Vec::new();
|
||||
let mut last_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new();
|
||||
let mut previous_row = u64::from(r.start.row);
|
||||
for (row, changes) in events {
|
||||
if previous_row < row {
|
||||
let start_row = previous_row as u32;
|
||||
let end_row = (row - 1).min(u64::from(r.end.row)) as u32;
|
||||
let mut covered = Vec::<(u32, u32)>::new();
|
||||
for &(start, end) in active.keys() {
|
||||
match covered.last_mut() {
|
||||
Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end),
|
||||
_ => covered.push((start, end)),
|
||||
}
|
||||
}
|
||||
let mut next_col = u64::from(r.start.col);
|
||||
let mut remaining = Vec::new();
|
||||
for (start, end) in covered {
|
||||
if next_col < u64::from(start) {
|
||||
remaining.push((next_col as u32, start - 1));
|
||||
}
|
||||
next_col = u64::from(end) + 1;
|
||||
}
|
||||
if next_col <= u64::from(r.end.col) {
|
||||
remaining.push((next_col as u32, r.end.col));
|
||||
}
|
||||
for (start_col, end_col) in remaining {
|
||||
let columns = (start_col, end_col);
|
||||
if let Some(&index) = last_by_columns.get(&columns)
|
||||
&& out[index].end.row.saturating_add(1) == start_row
|
||||
{
|
||||
out[index].end.row = end_row;
|
||||
} else {
|
||||
let index = out.len();
|
||||
out.push(RangeRef::new(CellRef::new(start_row, start_col), CellRef::new(end_row, end_col)));
|
||||
last_by_columns.insert(columns, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (columns, add) in changes {
|
||||
if add {
|
||||
*active.entry(columns).or_default() += 1;
|
||||
} else if let Some(count) = active.get_mut(&columns) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
active.remove(&columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
previous_row = row;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -43,9 +93,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
let before = RangeRef::top_left(&dv.ranges);
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
}
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_ranges(*r, cuts)).collect();
|
||||
if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) {
|
||||
dv.f1 = move_rule_formula(&dv.f1, from, to);
|
||||
dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to));
|
||||
|
||||
@@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values};
|
||||
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
|
||||
#[cfg(test)]
|
||||
pub(crate) use clipboard::parse_tsv;
|
||||
use clipboard::{clear_clipboard, copy, cut, paste};
|
||||
use clipboard::{clear_clipboard, copy, cut, paste, range_move};
|
||||
use fill::{auto_fill, fill_dir, fill_series, flash_fill};
|
||||
#[cfg(test)]
|
||||
use find::replace_ci;
|
||||
@@ -36,7 +36,7 @@ use go_to::go_to_special;
|
||||
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
|
||||
use paste_special::paste_special;
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -62,6 +62,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
|
||||
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
|
||||
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
|
||||
cmd!(
|
||||
noundo "selection.nextCorner",
|
||||
"Next Corner of Selection",
|
||||
[],
|
||||
Some("Ctrl+."),
|
||||
"{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)",
|
||||
has_doc,
|
||||
selection_next_corner
|
||||
),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
|
||||
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
|
||||
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
|
||||
@@ -81,6 +90,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"range.move",
|
||||
"Drag and Drop",
|
||||
[],
|
||||
None,
|
||||
"{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)",
|
||||
has_doc,
|
||||
range_move
|
||||
),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Ctrl+Cmd+V"),
|
||||
..cmd!(
|
||||
@@ -126,13 +144,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fill_series
|
||||
),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find),
|
||||
cmd!(
|
||||
"edit.replace",
|
||||
"Replace…",
|
||||
["Home", "Editing", "Find & Select"],
|
||||
Some("Ctrl+H"),
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -221,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
|
||||
@@ -244,21 +267,36 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
})
|
||||
})
|
||||
};
|
||||
// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
|
||||
// doesn't see them go: a locked cell's are refused here, stored cell or not.
|
||||
if matches!(what, "all" | "comments" | "hyperlinks") {
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) {
|
||||
for &whole in &ranges {
|
||||
let skip = filtered_rows(sh, whole);
|
||||
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
|
||||
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
|
||||
for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) {
|
||||
if d.wb.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
for r in &ranges {
|
||||
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
Some(u) => match r.intersection(&u) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
for &whole in &ranges {
|
||||
// Cells are only stored inside the used range. Notes and links are kept apart from
|
||||
// them, so they are cleared over the whole range: one from a file can sit on a cell
|
||||
// with nothing stored in it.
|
||||
let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u));
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default();
|
||||
let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) {
|
||||
(Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
|
||||
let cells: Vec<(CellRef, Cell)> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
// Report every stored cell we touch, even beyond a million coordinates in a sparse
|
||||
// selection. Removed formula anchors must also reach calc so their spills disappear.
|
||||
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
|
||||
@@ -278,6 +316,18 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row);
|
||||
// A removed note or link changes its cell even with nothing stored there.
|
||||
let metadata: Vec<CellRef> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| {
|
||||
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
|
||||
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
|
||||
notes.chain(links).copied().filter(|c| cleared(*c)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
cx.changed.extend(metadata.into_iter().map(|c| (sheet, c)));
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, mut cell) in cells {
|
||||
match what {
|
||||
@@ -294,14 +344,15 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
|
||||
if matches!(what, "all" | "comments") {
|
||||
sh.comments.retain(|c, _| !cleared(*c));
|
||||
}
|
||||
if matches!(what, "all" | "hyperlinks") {
|
||||
sh.hyperlinks.retain(|c, _| !cleared(*c));
|
||||
}
|
||||
if matches!(what, "all" | "formats") {
|
||||
if let Some(r) = used
|
||||
&& matches!(what, "all" | "formats")
|
||||
{
|
||||
let mut unmerged = false;
|
||||
sh.merges.retain(|m| {
|
||||
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::paste_special::paste_special;
|
||||
use super::{
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, 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
|
||||
@@ -14,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| {
|
||||
@@ -30,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
|
||||
@@ -45,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
|
||||
@@ -79,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 });
|
||||
@@ -97,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);
|
||||
@@ -119,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))
|
||||
}
|
||||
|
||||
@@ -163,6 +274,40 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
|
||||
}
|
||||
|
||||
/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on
|
||||
/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a
|
||||
/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A
|
||||
/// refused or failed move leaves the clipboard as it was.
|
||||
pub(super) fn range_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?;
|
||||
let kept = s.clipboard.take();
|
||||
let moved = cut(s, p).and_then(|_| {
|
||||
let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default();
|
||||
let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end));
|
||||
if bool_param(p, "overwrite") != Some(true)
|
||||
&& let Some(dest) = dest
|
||||
&& let Some(sh) = s.doc()?.wb.sheet(sheet)
|
||||
&& (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content())
|
||||
|| sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty))
|
||||
{
|
||||
return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into()));
|
||||
}
|
||||
// On the range's sheet, which `paste` would leave to the active one. The selection
|
||||
// belongs to the active sheet: it follows the moved cells only when they are on it.
|
||||
let selection = s.doc()?.selection.clone();
|
||||
let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?;
|
||||
if sheet != s.doc()?.wb.active_sheet() {
|
||||
s.doc_mut()?.selection = selection;
|
||||
}
|
||||
Ok(pasted)
|
||||
});
|
||||
if moved.is_err() {
|
||||
s.clipboard = kept;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
|
||||
@@ -226,16 +371,55 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
let mut row = Vec::new();
|
||||
let mut field = String::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
|
||||
// quote but turned out not to be quoted.
|
||||
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
|
||||
// Text is replayed at most as much as the input is long; past that, quotes are text, so
|
||||
// hostile input full of fields that only look quoted reads in linear time.
|
||||
let mut replay_budget = text.len();
|
||||
let mut quoted = false;
|
||||
let mut at_start = true;
|
||||
while let Some(c) = chars.next() {
|
||||
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
|
||||
let mut raw = String::new();
|
||||
loop {
|
||||
let c = match replay.pop_front().or_else(|| chars.next()) {
|
||||
Some(c) => c,
|
||||
// A quote that never closes: the field is its text as written.
|
||||
None if quoted => {
|
||||
quoted = false;
|
||||
field.clear();
|
||||
field.push('"');
|
||||
replay_budget = replay_budget.saturating_sub(raw.len());
|
||||
replay.extend(raw.chars().skip(1));
|
||||
raw.clear();
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
};
|
||||
if quoted {
|
||||
raw.push(c);
|
||||
if c == '"' {
|
||||
if chars.peek() == Some(&'"') {
|
||||
let next = replay.front().copied().or_else(|| chars.peek().copied());
|
||||
if next == Some('"') {
|
||||
field.push('"');
|
||||
chars.next();
|
||||
raw.push('"');
|
||||
if replay.pop_front().is_none() {
|
||||
chars.next();
|
||||
}
|
||||
} else {
|
||||
quoted = false;
|
||||
// A field is quoted only when its closing quote ends it, as Excel reads it:
|
||||
// `"hi" there` is that text, quotes included, and a tab or line break inside
|
||||
// it still ends the cell, as if the leading quote weren't there.
|
||||
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
|
||||
field.clear();
|
||||
field.push('"');
|
||||
replay_budget = replay_budget.saturating_sub(raw.len());
|
||||
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
|
||||
for ch in rest.into_iter().rev() {
|
||||
replay.push_front(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
@@ -243,9 +427,10 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if at_start => {
|
||||
'"' if at_start && replay_budget > 0 => {
|
||||
quoted = true;
|
||||
at_start = false;
|
||||
raw = String::from('"');
|
||||
}
|
||||
'\t' => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
// `selection`: only the selected cells of the active sheet, as Excel searches a selected range.
|
||||
let in_selection = str_param(p, "within") == Some("selection");
|
||||
s.last_find = Some(p.clone());
|
||||
let d = s.doc()?;
|
||||
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
|
||||
@@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut cells: Vec<(CellRef, String)> = sh
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|(c, _)| !in_selection || d.selection.contains(*c))
|
||||
.filter_map(|(c, cell)| {
|
||||
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
|
||||
crate::display::cell_text(&d.wb, sh, c)
|
||||
@@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
|
||||
let dd = s.doc_mut()?;
|
||||
dd.wb_switch_sheet(si);
|
||||
dd.selection = crate::Selection::at(c);
|
||||
if in_selection {
|
||||
// The range stays selected and the active cell moves through it, as in Excel.
|
||||
dd.selection.active = c;
|
||||
} else {
|
||||
dd.selection = crate::Selection::at(c);
|
||||
}
|
||||
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
|
||||
}
|
||||
|
||||
@@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let whole = bool_param(p, "wholeCell").unwrap_or(false);
|
||||
let all = bool_param(p, "all").unwrap_or(true);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?;
|
||||
let active = s.doc()?.selection.active;
|
||||
let mut skipped = 0usize;
|
||||
let n = edit(s, |cx| {
|
||||
@@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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();
|
||||
for (c, text) in cells {
|
||||
if !all && c != active {
|
||||
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
|
||||
continue;
|
||||
}
|
||||
if !matches(&text, &what, case, whole) {
|
||||
|
||||
@@ -4,74 +4,7 @@ use super::{
|
||||
target_sheet,
|
||||
};
|
||||
|
||||
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
|
||||
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
|
||||
}
|
||||
|
||||
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
|
||||
let operand = new.value.clone();
|
||||
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
|
||||
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
|
||||
&& (operand.is_number() || operand.is_empty())
|
||||
{
|
||||
new.value = match op {
|
||||
"add" => Value::number(x + y),
|
||||
"subtract" => Value::number(x - y),
|
||||
"multiply" => Value::number(x * y),
|
||||
"divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0),
|
||||
"divide" => Value::number(x / y),
|
||||
_ => operand.clone(),
|
||||
};
|
||||
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
|
||||
new.value = old.value.clone();
|
||||
}
|
||||
if let Some(formula) = &old.formula
|
||||
&& let Ok(y) = operand.to_number()
|
||||
&& (operand.is_number() || operand.is_empty())
|
||||
&& let Some(symbol) = operation_symbol(op)
|
||||
{
|
||||
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
||||
new.value = Value::Empty;
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_symbol(op: &str) -> Option<char> {
|
||||
match op {
|
||||
"add" => Some('+'),
|
||||
"subtract" => Some('-'),
|
||||
"multiply" => Some('*'),
|
||||
"divide" => Some('/'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_merges(
|
||||
cx: &mut crate::cmd::Ctx,
|
||||
dest_sheet: usize,
|
||||
src_sheet: &gridcraft_model::Sheet,
|
||||
src: RangeRef,
|
||||
at: CellRef,
|
||||
what: What,
|
||||
transpose: bool,
|
||||
) -> Result<()> {
|
||||
if !matches!(what, What::All | What::Formats) || transpose {
|
||||
return Ok(());
|
||||
}
|
||||
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
|
||||
for merge in merges {
|
||||
let row_offset = at.row as i64 - src.start.row as i64;
|
||||
let col_offset = at.col as i64 - src.start.col as i64;
|
||||
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
|
||||
let moved = RangeRef::new(start, end);
|
||||
let sheet = cx.sheet_mut(dest_sheet)?;
|
||||
sheet.merges.retain(|existing| !existing.intersects(&moved));
|
||||
sheet.merges.push(moved);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)] // The command applies the selected paste mode in one atomic edit.
|
||||
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
|
||||
let what = match str_param(p, "what").unwrap_or("all") {
|
||||
@@ -97,7 +30,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let dest_sheet = target_sheet(s, p)?;
|
||||
let sel = s.doc()?.selection.current();
|
||||
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
|
||||
let src = clip.range;
|
||||
// Whole columns or rows paste whole (the copy keeps only their used part as its text), so
|
||||
// what the destination columns hold further down is replaced too, as in Excel. The cells are
|
||||
// visited sparsely below, so this costs what the columns hold, not 1,048,576 rows.
|
||||
let whole = clip.areas.is_empty() && (clip.selected.is_full_cols() || clip.selected.is_full_rows()) && clip.selected.contains_range(&clip.range);
|
||||
let src = if whole { clip.selected } else { clip.range };
|
||||
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
|
||||
// Tile the copy over a larger destination that is a multiple of its size.
|
||||
let tile = sel.height() % h == 0
|
||||
@@ -108,17 +45,31 @@ 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() {
|
||||
return Err(EngineError::Other("The copied cells don't fit here: paste them where the whole copied area fits on the sheet.".into()));
|
||||
}
|
||||
// Paste Link writes a formula into every cell, blank ones included, so it is the one paste
|
||||
// whose work grows with the area rather than with what the source holds.
|
||||
let link_src = clip.range;
|
||||
if link && link_src.count().saturating_mul(u64::from(tiles_r) * u64::from(tiles_c)) > MAX_PASTE_LINK_CELLS {
|
||||
return Err(EngineError::Other("Too many cells to paste as links at once.".into()));
|
||||
}
|
||||
let same_doc = s.doc()?.uid == clip.doc_uid;
|
||||
let cut_move = clip.cut && same_doc && what == What::All;
|
||||
let src_wb = clip.wb.clone();
|
||||
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
|
||||
let src_sheet_name = src_sheet.name.clone();
|
||||
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
||||
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
||||
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Styles from another workbook must be re-interned.
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
let mut style_map = std::collections::HashMap::new();
|
||||
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
|
||||
if same_doc {
|
||||
@@ -126,7 +77,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
|
||||
};
|
||||
let mut moved_formulas = Vec::new();
|
||||
let mut moved_formulas = std::collections::BTreeMap::new();
|
||||
let mut moved_pictures = std::collections::BTreeMap::new();
|
||||
if cut_move {
|
||||
// Remove the source first (contents and formats), and fix references to it.
|
||||
@@ -143,190 +94,284 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (c, _) in &taken {
|
||||
cx.touch(clip.sheet, *c);
|
||||
}
|
||||
moved_formulas = taken;
|
||||
moved_formulas = taken.into_iter().collect();
|
||||
}
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
|
||||
let sc = CellRef::new(src.start.row + r, src.start.col + c);
|
||||
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
|
||||
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
|
||||
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
|
||||
let src_val = src_sheet.value(sc);
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
|
||||
let mut new_picture = old_picture.clone();
|
||||
let mut new = old.clone();
|
||||
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
if cut_move {
|
||||
// A moved formula still reads cells on its original sheet. Qualify
|
||||
// those references before the workbook-wide move adjustment below.
|
||||
let e = if dest_sheet != clip.sheet {
|
||||
e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
} else {
|
||||
e
|
||||
};
|
||||
let mut moved = Formula::from_expr(e);
|
||||
// The array range moves with its formula.
|
||||
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||
moved.dynamic = f.dynamic;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
|
||||
// cells its `xl()` references named on its original sheet, as a moved formula does:
|
||||
// they are qualified with that sheet before the workbook-wide move adjustment below.
|
||||
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
||||
let Some(py) = cell.py.clone() else { return Ok(None) };
|
||||
if !cut_move || dest_sheet == clip.sheet {
|
||||
return Ok(Some(py));
|
||||
}
|
||||
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Some(gridcraft_formula::print(&e))
|
||||
// The cells of the copied block that hold something (or that a link reads), as offsets
|
||||
// inside it. Blank source cells matter only where the destination holds something.
|
||||
let source_offsets: std::collections::BTreeSet<(u32, u32)> = if link {
|
||||
(0..link_src.height()).flat_map(|r| (0..link_src.width()).map(move |c| (r, c))).collect()
|
||||
} else {
|
||||
let offset = |c: CellRef| (c.row - src.start.row, c.col - src.start.col);
|
||||
let mut held: std::collections::BTreeSet<(u32, u32)> = src_sheet.cells.iter_range(src).map(|(c, _)| offset(c)).collect();
|
||||
let side =
|
||||
src_sheet.spill.keys().chain(src_sheet.cell_pictures.keys()).chain(src_sheet.comments.keys()).chain(src_sheet.hyperlinks.keys());
|
||||
held.extend(side.chain(moved_formulas.keys()).chain(moved_pictures.keys()).filter(|c| src.contains(**c)).map(|c| offset(*c)));
|
||||
held
|
||||
};
|
||||
let skip_cells = matches!(what, What::Validation | What::ColumnWidths);
|
||||
// A blank source cell clears (or reformats) a destination cell that holds something, so
|
||||
// those cells join the visit. One scan of the whole pasted area sorts them by tile.
|
||||
let mut dest_held: std::collections::BTreeMap<(u32, u32), Vec<(u32, u32)>> = Default::default();
|
||||
if !link && !skip_cells {
|
||||
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
|
||||
let held =
|
||||
cx.wb.sheet(dest_sheet).into_iter().flat_map(|sh| {
|
||||
sh.cells.iter_range(pasted).map(|(c, _)| c).chain(sh.cell_pictures.keys().copied().filter(|c| pasted.contains(*c)))
|
||||
});
|
||||
Ok(Some(match qualified {
|
||||
Some(code) => {
|
||||
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
||||
check_python_fits(&moved, &dest_name, dest)?;
|
||||
std::sync::Arc::new(moved)
|
||||
for d in held.collect::<Vec<_>>() {
|
||||
let (dr, dc) = (d.row - at.row, d.col - at.col);
|
||||
let local = (dr % h, dc % w);
|
||||
dest_held.entry((dr / h, dc / w)).or_default().push(if transpose { (local.1, local.0) } else { local });
|
||||
}
|
||||
}
|
||||
let no_cells = std::collections::BTreeSet::new();
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
let merged;
|
||||
let offsets = match dest_held.get(&(tr, tc)) {
|
||||
Some(extra) => {
|
||||
merged = source_offsets.iter().chain(extra).copied().collect::<std::collections::BTreeSet<_>>();
|
||||
&merged
|
||||
}
|
||||
None => py,
|
||||
}))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
|
||||
None if skip_cells => &no_cells,
|
||||
None => &source_offsets,
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new.py = None;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
match what {
|
||||
What::All | What::AllExceptBorders => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
cx.wb.styles.derive(st, |s| s.borders = borders)
|
||||
for &(r, c) in offsets {
|
||||
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();
|
||||
if skip_blanks
|
||||
&& src_picture.is_none()
|
||||
&& src_val.is_empty()
|
||||
&& src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
|
||||
let mut new_picture = old_picture.clone();
|
||||
let mut new = old.clone();
|
||||
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
if cut_move {
|
||||
// A moved formula still reads cells on its original sheet. Qualify
|
||||
// those references before the workbook-wide move adjustment below.
|
||||
let e = if dest_sheet != clip.sheet {
|
||||
e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
} else {
|
||||
e
|
||||
};
|
||||
let mut moved = Formula::from_expr(e);
|
||||
// The array range moves with its formula.
|
||||
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||
moved.dynamic = f.dynamic;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
||||
let Some(py) = cell.py.clone() else { return Ok(None) };
|
||||
if !cut_move || dest_sheet == clip.sheet {
|
||||
return Ok(Some(py));
|
||||
}
|
||||
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Some(gridcraft_formula::print(&e))
|
||||
});
|
||||
Ok(Some(match qualified {
|
||||
Some(code) => {
|
||||
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
||||
check_python_fits(&moved, &dest_name, dest)?;
|
||||
std::sync::Arc::new(moved)
|
||||
}
|
||||
None => py,
|
||||
}))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
} else {
|
||||
st
|
||||
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
|
||||
};
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new.py = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
match what {
|
||||
What::All | What::AllExceptBorders => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
cx.wb.styles.derive(st, |s| s.borders = borders)
|
||||
} else {
|
||||
st
|
||||
};
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.py = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
&& new.py.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
}
|
||||
let a = old.value.clone();
|
||||
let b = new.value.clone();
|
||||
// Numbers stored as text take part (multiplying by 1 turns them into
|
||||
// numbers); other text and errors stay as they are.
|
||||
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
|
||||
&& (a.is_number() || a.is_empty() || a.is_text())
|
||||
&& (b.is_number() || b.is_empty())
|
||||
{
|
||||
new.value = match op.as_str() {
|
||||
"add" => Value::number(x + y),
|
||||
"subtract" => Value::number(x - y),
|
||||
"multiply" => Value::number(x * y),
|
||||
"divide" => {
|
||||
if y == 0.0 {
|
||||
Value::Error(gridcraft_core::CellError::Div0)
|
||||
} else {
|
||||
Value::number(x / y)
|
||||
}
|
||||
}
|
||||
_ => b.clone(),
|
||||
};
|
||||
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
|
||||
new.value = a;
|
||||
}
|
||||
// A formula already there is kept and combined with the pasted number.
|
||||
if let Some(f) = &old.formula
|
||||
&& let Ok(y) = b.to_number()
|
||||
&& (b.is_number() || b.is_empty())
|
||||
&& let Some(sym) = match op.as_str() {
|
||||
"add" => Some('+'),
|
||||
"subtract" => Some('-'),
|
||||
"multiply" => Some('*'),
|
||||
"divide" => Some('/'),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
||||
new.value = Value::Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
&& new.py.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
}
|
||||
What::All => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
}
|
||||
// Numbers stored as text participate; other text and errors stay as they are.
|
||||
apply_operation(&op, &old, &mut new);
|
||||
if let Some(picture) = new_picture {
|
||||
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
cx.touch(dest_sheet, dest);
|
||||
}
|
||||
}
|
||||
// The copied cell's validation rule replaces whatever rule the destination had.
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
||||
if let Some(mut dv) = rule {
|
||||
// Relative references move with the rule, as pasted formulas do.
|
||||
let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc);
|
||||
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
|
||||
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
|
||||
dv.ranges = vec![RangeRef::new(dest, dest)];
|
||||
sh.validations.push(dv);
|
||||
}
|
||||
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, 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
|
||||
// copied rows land in columns and all take the last copied column's width).
|
||||
let cols = cx.sheet_mut(dest_sheet)?;
|
||||
for dc in 0..pasted.width() {
|
||||
let info = if transpose {
|
||||
(0..src.width()).rev().find_map(|c| src_sheet.cols.get(&(src.start.col + c)).copied())
|
||||
} else {
|
||||
src_sheet.cols.get(&(src.start.col + dc % w)).copied()
|
||||
};
|
||||
if let Some(info) = info {
|
||||
cols.cols.insert(pasted.start.col + dc, info);
|
||||
}
|
||||
}
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
}
|
||||
What::ColumnWidths => {
|
||||
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
|
||||
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
|
||||
}
|
||||
}
|
||||
What::All => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
}
|
||||
if let Some(picture) = new_picture {
|
||||
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
cx.touch(dest_sheet, dest);
|
||||
}
|
||||
// Merged areas inside the copied block travel with it.
|
||||
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
|
||||
if matches!(what, What::All | What::Formats) && !transpose {
|
||||
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
|
||||
for m in merges {
|
||||
let dr = at.row as i64 - src.start.row as i64;
|
||||
let dc = at.col as i64 - src.start.col as i64;
|
||||
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
let nm = RangeRef::new(a, b);
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
sh.merges.retain(|x| !x.intersects(&nm));
|
||||
sh.merges.push(nm);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cut_move {
|
||||
// References elsewhere to the moved block now point to its new place.
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let e = if dest_sheet == clip.sheet {
|
||||
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
|
||||
} else {
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name.clone(), to_row: at.row, to_col: at.col }
|
||||
};
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
|
||||
cx.structural = true;
|
||||
@@ -351,6 +396,161 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"pasted": s.doc()?.selection.a1()}))
|
||||
}
|
||||
|
||||
/// Most cells one Paste Link writes (it writes a formula into every cell of the copied area,
|
||||
/// blank ones included).
|
||||
const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
|
||||
/// Most validation ranges one paste adds (a rule covering part of a small block tiled over a huge
|
||||
/// 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.
|
||||
/// `size` is one tile's (height, width) at the destination; `pasted` is all the tiles together.
|
||||
fn paste_validations(
|
||||
cx: &mut crate::cmd::Ctx<'_>,
|
||||
src_sheet: &gridcraft_model::Sheet,
|
||||
src: RangeRef,
|
||||
dest_sheet: usize,
|
||||
pasted: RangeRef,
|
||||
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();
|
||||
// Under Skip Blanks the destination keeps its rule below a blank source cell that has none, so
|
||||
// only the destination of cells that hold something (and of the rules) is cleared. Nothing is
|
||||
// collected when no destination rule is under the paste.
|
||||
let mut cleared: Vec<RangeRef> = Vec::new();
|
||||
let dest_rules =
|
||||
cx.wb.sheet(dest_sheet).is_some_and(|sh| sh.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.intersection(&pasted).is_some())));
|
||||
if skip_blanks && dest_rules {
|
||||
let mut cells = Vec::new();
|
||||
for (sc, cell) in src_sheet.cells.iter_range(src) {
|
||||
if cell.formula.is_none() && cell.py.is_none() && src_sheet.value(sc).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (r, c) = (sc.row - src.start.row, sc.col - src.start.col);
|
||||
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))
|
||||
&& !skip_rows.contains(&d.row)
|
||||
{
|
||||
cells.push((d.row, d.col));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coalesce populated cells into horizontal runs, then join identical runs in adjacent
|
||||
// rows. The range limit applies to the cuts, not the number of populated cells.
|
||||
cells.sort_unstable();
|
||||
let mut runs: Vec<RangeRef> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < cells.len() {
|
||||
let (row, start_col) = cells[i];
|
||||
let mut end_col = start_col;
|
||||
i += 1;
|
||||
while i < cells.len() && cells[i].0 == row && cells[i].1 <= end_col.saturating_add(1) {
|
||||
end_col = end_col.max(cells[i].1);
|
||||
i += 1;
|
||||
}
|
||||
runs.push(RangeRef::new(CellRef::new(row, start_col), CellRef::new(row, end_col)));
|
||||
}
|
||||
let mut last_run_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new();
|
||||
for run in runs {
|
||||
let columns = (run.start.col, run.end.col);
|
||||
if let Some(&index) = last_run_by_columns.get(&columns)
|
||||
&& cleared[index].end.row.saturating_add(1) == run.start.row
|
||||
{
|
||||
cleared[index].end.row = run.end.row;
|
||||
} else {
|
||||
let index = cleared.len();
|
||||
cleared.push(run);
|
||||
last_run_by_columns.insert(columns, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for dv in &src_sheet.validations {
|
||||
let parts: Vec<RangeRef> = dv.ranges.iter().filter_map(|r| r.intersection(&src)).collect();
|
||||
let Some(first) = dv.ranges.first().map(|r| r.start) else { continue };
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
if parts.contains(&src) {
|
||||
ranges.push(pasted);
|
||||
} else {
|
||||
total = total.saturating_add(parts.len().saturating_mul(tiles_r as usize).saturating_mul(tiles_c as usize));
|
||||
if total > MAX_PASTE_VALIDATION_RANGES {
|
||||
return Err(EngineError::Other("Too many separate validation areas to paste at once.".into()));
|
||||
}
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
let Some(origin) = pasted.start.offset(i64::from(tr * h), i64::from(tc * w)) else { continue };
|
||||
for p in &parts {
|
||||
let (r0, c0) = (p.start.row - src.start.row, p.start.col - src.start.col);
|
||||
let (r1, c1) = (p.end.row - src.start.row, p.end.col - src.start.col);
|
||||
let ((r0, c0), (r1, c1)) = if transpose { ((c0, r0), (c1, r1)) } else { ((r0, c0), (r1, c1)) };
|
||||
let Some(a) = origin.offset(i64::from(r0), i64::from(c0)) else { continue };
|
||||
let b = origin.offset_clamped(i64::from(r1), i64::from(c1));
|
||||
ranges.push(RangeRef::new(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// rule's first cell.
|
||||
rule.f1 = super::super::data::move_rule_formula(&dv.f1, first, anchor);
|
||||
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, first, anchor));
|
||||
rule.ranges = ranges;
|
||||
rules.push(rule);
|
||||
}
|
||||
if cleared.len() > MAX_PASTE_VALIDATION_RANGES {
|
||||
return Err(EngineError::Other("Too many separate validation areas to paste at once.".into()));
|
||||
}
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
if skip_blanks {
|
||||
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, &without_rows(pasted, skip_rows));
|
||||
}
|
||||
sh.validations.extend(rules);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> {
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
@@ -367,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));
|
||||
}
|
||||
}
|
||||
@@ -396,14 +596,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
}
|
||||
|
||||
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
||||
/// name and bubble sizes, and the source block it was made from). Every edit that moves
|
||||
/// name and bubble sizes, and the source block it was made from, and a form control's linked
|
||||
/// cell). Every edit that moves
|
||||
/// references calls it: inserting and deleting lines and cells and cut-moves (through
|
||||
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
|
||||
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
|
||||
/// block that no longer exists is dropped.
|
||||
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
|
||||
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.is_empty()).map(|s| s.name.clone()) else { continue };
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
let fix = |text: &str| -> String {
|
||||
match gridcraft_formula::parse(text) {
|
||||
@@ -420,6 +621,9 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
|
||||
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
for c in sh.controls.iter_mut() {
|
||||
c.link = c.link.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -137,6 +144,23 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"active": d.selection.active.a1()}))
|
||||
}
|
||||
|
||||
pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
// A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one
|
||||
// the sheet's protection doesn't let you select, and a merged cell that starts outside the
|
||||
// selected area are skipped.
|
||||
let area = d.selection.current();
|
||||
let stop = |c: CellRef| {
|
||||
Some(sheet.merge_at(c).map_or(c, |m| m.start))
|
||||
.filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c))
|
||||
};
|
||||
let mut next = d.selection.clone();
|
||||
next.next_corner_by(stop);
|
||||
d.selection = next;
|
||||
Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
pub(super) fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
|
||||
@@ -307,6 +307,9 @@ fn tsv() {
|
||||
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
|
||||
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
|
||||
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
|
||||
// #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it.
|
||||
assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]);
|
||||
assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,6 +356,74 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() {
|
||||
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_asks_before_replacing_data() {
|
||||
// #3136: dragging column B's border onto column A replaced A without asking.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "D9"})).unwrap();
|
||||
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone();
|
||||
let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "There's already data here. Do you want to replace it?");
|
||||
// Nothing moved, and the copy made before is still on the clipboard.
|
||||
assert_eq!(text(&mut s, "A1"), json!("Apple"));
|
||||
assert_eq!(text(&mut s, "B1"), json!("Kiwi"));
|
||||
assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9"));
|
||||
// Confirmed, column B replaces column A.
|
||||
s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap();
|
||||
assert_eq!(text(&mut s, "A1"), json!("Kiwi"));
|
||||
assert_eq!(text(&mut s, "A3"), json!("Lime"));
|
||||
assert_eq!(text(&mut s, "B1"), json!(""));
|
||||
// Onto empty cells, or overlapping only the moved cells themselves, nothing is asked.
|
||||
s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap();
|
||||
assert_eq!(text(&mut s, "A4"), json!("Lime"));
|
||||
s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap();
|
||||
assert_eq!(text(&mut s, "C2"), json!("Kiwi"));
|
||||
assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_asks_before_replacing_spilled_values() {
|
||||
// Cells showing a dynamic array's spilled values hold no cell of their own, but they are data.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap();
|
||||
let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "There's already data here. Do you want to replace it?");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum"));
|
||||
s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
|
||||
// The move was checked on the range's sheet but pasted on the active one, replacing its data
|
||||
// without asking.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C5"})).unwrap();
|
||||
s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap();
|
||||
// Sheet1 stays active with its own selection; the moved cells are on Two.
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "C5");
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let two = wb.sheet_index("Two").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
@@ -371,3 +442,141 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_of_selection_goes_clockwise_and_anchors_there() {
|
||||
// #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows
|
||||
// then move the corner opposite the new active cell.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!(r["selection"], "B2:D4");
|
||||
seen.push(r["active"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(seen, ["D2", "D4", "B4", "B2"]);
|
||||
s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4");
|
||||
// One row goes end to end; a single cell stays.
|
||||
s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2");
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
|
||||
s.execute("selection.set", json!({"range": "C5"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() {
|
||||
// With locked cells unselectable, Ctrl+. goes on to the next corner it may select.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked");
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() {
|
||||
// A corner inside a merged cell is that merged cell, whose active cell is its top-left one.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
let seen: Vec<String> = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(seen, ["C2", "D3", "B3", "B2"]);
|
||||
// A selection that is one merged cell has no other corner.
|
||||
s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() {
|
||||
// A corner in a hidden column is passed over, as keyboard moves pass over hidden cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3");
|
||||
// Column C selected with B1:C1 merged: its top corner's merged cell starts in column B,
|
||||
// outside the selection, so the active cell doesn't go there.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "C5"})).unwrap();
|
||||
s.execute("selection.column", json!({})).unwrap();
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
|
||||
// #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a
|
||||
// tab or line break, and pastes the result back as the same cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n");
|
||||
let rows = parse_tsv(&text);
|
||||
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
|
||||
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
|
||||
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
|
||||
// never closes), the text is read as written, and the tabs and line breaks inside it still
|
||||
// separate cells, as they would have without the leading quote.
|
||||
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
|
||||
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
|
||||
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
|
||||
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
|
||||
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
|
||||
// input's length), and every one is still its own cell.
|
||||
let hostile = "\"x\t".repeat(50_000);
|
||||
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
|
||||
}
|
||||
|
||||
/// `within: selection` searches only the selected cells, as Excel does with a range selected
|
||||
/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range
|
||||
/// selected, and Replace changes only them.
|
||||
#[test]
|
||||
fn find_and_replace_within_the_selection() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]}))
|
||||
.unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
|
||||
let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap();
|
||||
let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect();
|
||||
assert_eq!(cells, ["B1", "B2", "B4"], "only column B");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range");
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected");
|
||||
assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range");
|
||||
assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let at = |a: &str| sh.value(CellRef::parse(a).unwrap());
|
||||
assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear")));
|
||||
// The sheet scope is unchanged.
|
||||
let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap();
|
||||
assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet");
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Bring Forward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, toFront?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, true)
|
||||
),
|
||||
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Send Backward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, toBack?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, false)
|
||||
),
|
||||
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
has_doc,
|
||||
align
|
||||
),
|
||||
|
||||
@@ -57,6 +57,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
let kind = kind_param(p, "arrange")?;
|
||||
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?;
|
||||
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
|
||||
if i >= v.len() {
|
||||
return;
|
||||
@@ -81,6 +82,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
ObjKind::Shape | ObjKind::Equation => {
|
||||
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
|
||||
}
|
||||
ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)),
|
||||
};
|
||||
found.ok_or_else(|| bad("arrange", "no such object"))?;
|
||||
Ok(Json::Null)
|
||||
@@ -106,6 +108,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
|
||||
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
// Form controls paint above everything else.
|
||||
for c in &sh.controls {
|
||||
v.push(
|
||||
json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}),
|
||||
);
|
||||
}
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
@@ -115,6 +123,7 @@ fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
|
||||
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Control => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
}
|
||||
}
|
||||
@@ -133,6 +142,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let how = str_param(p, "align").unwrap_or("left").to_string();
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?;
|
||||
edit(s, |cx| {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let geo: Vec<(u32, f64, f64, f32, f32)> = {
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Options",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange, precisionAsDisplayed}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
always,
|
||||
options
|
||||
),
|
||||
@@ -230,7 +230,7 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
|
||||
gridcraft_model::CalcMode::Manual => "manual",
|
||||
};
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change, "precisionAsDisplayed": c.precision_as_displayed})
|
||||
});
|
||||
Ok(json!({"calculation": calculation}))
|
||||
}
|
||||
|
||||
@@ -427,6 +427,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
|
||||
@@ -9,6 +9,17 @@ use super::{
|
||||
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
|
||||
/// sheet, so another sheet needs explicit lines.
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
|
||||
let (sheet, lines) = resolve_lines(s, p, rows)?;
|
||||
// Sizes and visibility are formatting of the line, which a protected sheet must allow.
|
||||
if rows {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?;
|
||||
} else {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?;
|
||||
}
|
||||
Ok((sheet, lines))
|
||||
}
|
||||
|
||||
fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
|
||||
let key = if rows { "rows" } else { "cols" };
|
||||
let mut sheet = target_sheet(s, p)?;
|
||||
if let Some(t) = str_param(p, key) {
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Calculation Options",
|
||||
["Formulas", "Calculation"],
|
||||
None,
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001), precisionAsDisplayed?: bool (Set precision as displayed: numbers are stored as their format shows them; turning it on rounds every number constant permanently)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
has_doc,
|
||||
calc_options
|
||||
),
|
||||
@@ -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)) {
|
||||
@@ -954,14 +951,30 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(v) = f64_param(p, "maxChange") {
|
||||
cx.wb.calc.set_max_change(v);
|
||||
}
|
||||
let precision = bool_param(p, "precisionAsDisplayed");
|
||||
if let Some(v) = precision {
|
||||
cx.wb.calc.precision_as_displayed = v;
|
||||
if v && !before.precision_as_displayed {
|
||||
crate::cmd::round_all_constants_as_displayed(&mut cx.wb);
|
||||
}
|
||||
}
|
||||
let c = &cx.wb.calc;
|
||||
// Switching to automatic, or changing how circular references are calculated outside
|
||||
// manual mode, recalculates the workbook.
|
||||
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
|
||||
// Switching precision as displayed recalculates the workbook, in manual mode too, so the
|
||||
// values change as soon as it is switched on or off.
|
||||
let precision_changed = c.precision_as_displayed != before.precision_as_displayed;
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) || precision_changed {
|
||||
cx.structural = true;
|
||||
}
|
||||
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
|
||||
Ok(json!({
|
||||
"mode": format!("{:?}", c.mode),
|
||||
"iterative": c.iterative,
|
||||
"maxIterations": c.max_iterations,
|
||||
"maxChange": c.max_change,
|
||||
"precisionAsDisplayed": c.precision_as_displayed,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, holeSize?, firstSliceAngle?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&with(p, "kind", json!("textBox"))
|
||||
)),
|
||||
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
|
||||
cmd!(
|
||||
"object.move",
|
||||
"Move Object",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
has_doc,
|
||||
move_object
|
||||
),
|
||||
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
has_doc,
|
||||
set_anchor_mode
|
||||
),
|
||||
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Hyperlink",
|
||||
[],
|
||||
None,
|
||||
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening",
|
||||
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error",
|
||||
has_doc,
|
||||
open_link
|
||||
),
|
||||
@@ -850,6 +850,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
|
||||
@@ -926,6 +928,12 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(v) = u32_param(p, "style") {
|
||||
c.style = v.clamp(1, 16);
|
||||
}
|
||||
if let Some(v) = u32_param(p, "holeSize") {
|
||||
c.hole_size = v.clamp(10, 90) as u8;
|
||||
}
|
||||
if let Some(v) = u32_param(p, "firstSliceAngle") {
|
||||
c.first_slice_angle = (v % 360) as u16;
|
||||
}
|
||||
if let Some(t) = p.get("xTitle") {
|
||||
c.x_title = t.as_str().map(str::to_string);
|
||||
}
|
||||
@@ -1100,6 +1108,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
|
||||
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
|
||||
let d = s.doc()?;
|
||||
// Sheet protection locks drawing objects, as for a text box's text.
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let scale = (640.0 / w.max(1) as f32).min(1.0);
|
||||
@@ -1231,13 +1243,16 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// objects are errors, so a stale id never succeeds silently.
|
||||
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
|
||||
let kind =
|
||||
str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let o = ObjRef::new(kind, id);
|
||||
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
|
||||
return Err(bad(cmd, "no such object"));
|
||||
}
|
||||
// A locked form control on a protected sheet stays as it is.
|
||||
super::controls::check_objects_unlocked(s, sheet, kind, &[id])?;
|
||||
Ok((sheet, o))
|
||||
}
|
||||
|
||||
@@ -1336,9 +1351,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
|
||||
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
|
||||
/// dialog; the interactive click asks for confirmation first, in the UI.
|
||||
/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]).
|
||||
/// An `http`/`https`/`mailto` address is refused too unless the host set
|
||||
/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so
|
||||
/// the user opens such links by clicking and confirming, and the UI then calls
|
||||
/// [`Session::open_confirmed_link`] (#2181). Opens no dialog.
|
||||
fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
|
||||
Some(t) => t.to_string(),
|
||||
@@ -1354,6 +1371,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
match crate::links::classify(&target) {
|
||||
crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
|
||||
crate::links::LinkKind::External(u) => {
|
||||
if s.link_policy != crate::links::LinkPolicy::Allow {
|
||||
return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into()));
|
||||
}
|
||||
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
||||
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Check Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
|
||||
"{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Step Into",
|
||||
[],
|
||||
None,
|
||||
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
|
||||
"{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
|
||||
has_doc,
|
||||
|s, p| crate::macros::debug::debug(s, p)
|
||||
),
|
||||
@@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
renamed.name = to.clone();
|
||||
// The whole check, size included: a longer name can take a macro past what a file holds.
|
||||
crate::macros::check(&renamed).map_err(EngineError::Other)?;
|
||||
*m = renamed;
|
||||
let from = std::mem::replace(m, renamed).name;
|
||||
// Buttons that run the macro follow the new name.
|
||||
let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from));
|
||||
for si in 0..cx.wb.sheets.len() {
|
||||
if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) {
|
||||
continue;
|
||||
}
|
||||
for c in cx.sheet_mut(si)?.controls.iter_mut() {
|
||||
if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action
|
||||
&& name.eq_ignore_ascii_case(&from)
|
||||
{
|
||||
*name = to.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": to}))
|
||||
|
||||
@@ -67,6 +67,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
|
||||
@@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec<Json> {
|
||||
}
|
||||
|
||||
pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
let source = source_param(s, p)?;
|
||||
let active = s.doc()?.wb.active_sheet();
|
||||
// Validate the source now (header names, size).
|
||||
@@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?;
|
||||
Some((si, at))
|
||||
};
|
||||
// Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet.
|
||||
if target.is_none() && s.doc()?.wb.protected_structure {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
let replace = bool_param(p, "replace").unwrap_or(false);
|
||||
let rows = field_list(p.get("rows"));
|
||||
let columns = field_list(p.get("columns"));
|
||||
|
||||
@@ -507,9 +507,13 @@ pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut v = cur.clone();
|
||||
if let Some(f) = func {
|
||||
v.func = f;
|
||||
// An automatic caption follows the function.
|
||||
let auto = auto_name(cur.func, &cur.source_col);
|
||||
if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) {
|
||||
// An automatic caption follows the function. Count Numbers was captioned "Count
|
||||
// Numbers of …" before it took Excel's "Count of …", and saved workbooks still carry it.
|
||||
let is_auto = |base: &str| {
|
||||
cur.name.eq_ignore_ascii_case(base) || cur.name.strip_prefix(base).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit()))
|
||||
};
|
||||
let legacy = cur.func == PivotFunc::CountNumbers && is_auto(&format!("Count Numbers of {}", cur.source_col));
|
||||
if is_auto(&auto_name(cur.func, &cur.source_col)) || legacy {
|
||||
v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() {
|
||||
assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err());
|
||||
}
|
||||
|
||||
/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the
|
||||
/// report still filters case-insensitively.
|
||||
#[test]
|
||||
fn a_huge_filter_selection_is_matched_through_a_set() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
|
||||
let mut selected: Vec<String> = (0..99_999).map(|i| format!("Region {i}")).collect();
|
||||
selected.push("EAST".into());
|
||||
s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap();
|
||||
let gt = find_row(&s, "Grand Total").unwrap();
|
||||
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_values_as() {
|
||||
let mut s = sales();
|
||||
@@ -1013,3 +1026,36 @@ fn detail_cells_are_the_cells_show_details_takes() {
|
||||
let pt = &d.wb.active().unwrap().pivots[0];
|
||||
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() {
|
||||
// #1448 items 10 and 12: Excel ranks 5, 4, 3, 3, 4 as 1, 2, 3, 3, 2 (GridCraft gave 1, 2, 4,
|
||||
// 4, 2), and captions a Count Numbers field "Count of Val".
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4], ["C", 3], ["D", 3], ["E", 4]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$6", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap();
|
||||
let ranks: Vec<Value> = ["E4", "E5", "E6", "E7", "E8"].iter().map(|a| val(&s, a)).collect();
|
||||
assert_eq!(ranks, [1.0, 2.0, 3.0, 3.0, 2.0].map(Value::Number));
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap();
|
||||
assert_eq!(text(&s, "E3"), "Count of Val");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_count_numbers_caption_follows_the_function() {
|
||||
// Workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val";
|
||||
// that is still an automatic caption, so it follows a new function. A custom name stays.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
|
||||
for (legacy, after) in [("Count Numbers of Val", "Sum of Val"), ("Count Numbers of Val2", "Sum of Val"), ("Numbers counted", "Numbers counted")] {
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "func": "countNumbers", "name": legacy})).unwrap();
|
||||
assert_eq!(text(&s, "E3"), legacy);
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "func": "sum"})).unwrap();
|
||||
assert_eq!(text(&s, "E3"), after, "{legacy}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,17 @@ pub struct HfSection {
|
||||
pub size: Option<f32>,
|
||||
}
|
||||
|
||||
/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name
|
||||
/// (or file path) each time it appears, and a JSON workbook's names have no length limit.
|
||||
const MAX_SECTION_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out.
|
||||
fn push_field(text: &mut String, s: &str) {
|
||||
if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES {
|
||||
text.push_str(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands a header/footer string into its left, centre and right sections.
|
||||
pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
let mut out: [HfSection; 3] = Default::default();
|
||||
@@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
i += 1 + digits.len();
|
||||
}
|
||||
}
|
||||
sec.text.push_str(&n.to_string());
|
||||
push_field(&mut sec.text, &n.to_string());
|
||||
}
|
||||
'N' | 'n' => sec.text.push_str(&cx.pages.to_string()),
|
||||
'D' | 'd' => sec.text.push_str(&cx.date),
|
||||
'T' | 't' => sec.text.push_str(&cx.time),
|
||||
'A' | 'a' => sec.text.push_str(&cx.sheet),
|
||||
'F' | 'f' => sec.text.push_str(&cx.file),
|
||||
'Z' | 'z' => sec.text.push_str(&cx.path),
|
||||
'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()),
|
||||
'D' | 'd' => push_field(&mut sec.text, &cx.date),
|
||||
'T' | 't' => push_field(&mut sec.text, &cx.time),
|
||||
'A' | 'a' => push_field(&mut sec.text, &cx.sheet),
|
||||
'F' | 'f' => push_field(&mut sec.text, &cx.file),
|
||||
'Z' | 'z' => push_field(&mut sec.text, &cx.path),
|
||||
'&' => sec.text.push('&'),
|
||||
'B' | 'b' => sec.bold = !sec.bold,
|
||||
'I' | 'i' => sec.italic = !sec.italic,
|
||||
|
||||
@@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() {
|
||||
assert_eq!(r.text, "P4/9 9:00 AM");
|
||||
assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain");
|
||||
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
|
||||
// A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB.
|
||||
let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx };
|
||||
let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long);
|
||||
assert!(c.text.is_empty(), "the name alone is over the limit");
|
||||
let short = HfContext { sheet: "S".repeat(1000), ..Default::default() };
|
||||
assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -695,6 +701,79 @@ fn bands_unit() {
|
||||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines.
|
||||
fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
for word in para.split(' ') {
|
||||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
|
||||
line = cand;
|
||||
} else {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let (mut lo, mut hi) = (1usize, chars.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
let cut: String = chars[..mid].iter().collect();
|
||||
if gridcraft_pdf::text_width(font, size, &cut) <= width {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
if lo == chars.len() {
|
||||
break;
|
||||
}
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_matches_the_remeasuring_wrapper() {
|
||||
let texts = [
|
||||
"the quick brown fox jumps",
|
||||
"Supercalifragilistic",
|
||||
"a b c\n\nverylongwordthatneedsbreaking short\nend ",
|
||||
"Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM",
|
||||
"€ ü ß — unicode Ünïcödé text",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
for text in texts {
|
||||
for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] {
|
||||
for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] {
|
||||
assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() {
|
||||
// One character per line: the old wrapper re-measured and copied the rest of the word for
|
||||
// each of the 100,000 lines before checking the limit.
|
||||
let word = "w".repeat(100_000);
|
||||
let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0);
|
||||
assert!(lines.len() <= 2002, "{}", lines.len());
|
||||
assert!(lines.iter().all(|l| l == "w"));
|
||||
// A long paragraph in a very wide box is one line.
|
||||
let para = vec!["word"; 50_000].join(" ");
|
||||
assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_spans_following_empty_center_across_cells() {
|
||||
let mut styles = StyleTable::default();
|
||||
|
||||
@@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet
|
||||
|
||||
const PAD_PX: f32 = 2.0;
|
||||
|
||||
/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph).
|
||||
const MAX_WRAP_LINES: usize = 2000;
|
||||
|
||||
/// Word-wraps `text` to `width` points.
|
||||
///
|
||||
/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each
|
||||
/// character is measured once: re-measuring the line for every word, or the rest of an
|
||||
/// over-long word for every piece cut from it, made a long paragraph or word quadratic.
|
||||
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() };
|
||||
let fits = |units: u64| units as f32 * size / 1000.0 <= width;
|
||||
let space = units_of(" ");
|
||||
let mut out = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let mut line_units: u64 = 0;
|
||||
for word in para.split(' ') {
|
||||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
|
||||
line = cand;
|
||||
let word_units = units_of(word);
|
||||
if line.is_empty() {
|
||||
line.push_str(word);
|
||||
line_units = word_units;
|
||||
} else if fits(line_units + space + word_units) {
|
||||
line.push(' ');
|
||||
line.push_str(word);
|
||||
line_units += space + word_units;
|
||||
} else {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
line.push_str(word);
|
||||
line_units = word_units;
|
||||
}
|
||||
// Break a single over-long word by characters. The cut that fits is found by binary
|
||||
// search (each try measures one candidate prefix), so an over-long word costs
|
||||
// O(n log n) measurements instead of re-measuring the whole cut per character for
|
||||
// every emitted line (`a` × 100k pasted text would be cubic worst case).
|
||||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let (mut lo, mut hi) = (1usize, chars.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
let cut: String = chars[..mid].iter().collect();
|
||||
if gridcraft_pdf::text_width(font, size, &cut) <= width {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
// Break a single over-long word by characters: each piece is the longest that fits
|
||||
// (at least one character).
|
||||
if !fits(line_units) && line.chars().nth(1).is_some() {
|
||||
let mut piece = String::new();
|
||||
let mut units: u64 = 0;
|
||||
for c in line.chars() {
|
||||
let w = u64::from(font.char_width(c));
|
||||
if !piece.is_empty() && !fits(units + w) {
|
||||
out.push(std::mem::take(&mut piece));
|
||||
units = 0;
|
||||
if out.len() > MAX_WRAP_LINES {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
piece.push(c);
|
||||
units += w;
|
||||
}
|
||||
if lo == chars.len() {
|
||||
break;
|
||||
}
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
line = piece;
|
||||
line_units = units;
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
if out.len() > MAX_WRAP_LINES {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
|
||||
}
|
||||
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
|
||||
// sheet is active: both carry the command's own `sheet` parameter on.
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
|
||||
// The checks must authorize the sheet this command modifies, not whatever sheet is active:
|
||||
// both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell).
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?;
|
||||
|
||||
let moved_from = if before <= from { from + count } else { from };
|
||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
|
||||
@@ -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() {
|
||||
@@ -170,7 +169,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
|
||||
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
|
||||
// the largest id).
|
||||
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
|
||||
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len() + sh.controls.len();
|
||||
let ids = cx.wb.free_object_ids(count);
|
||||
if ids.len() < count {
|
||||
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
|
||||
@@ -181,6 +180,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.images.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.controls.iter_mut().for_each(|o| o.id = fresh());
|
||||
let at = to.min(n);
|
||||
// The copy gets its own copies of the sheet's names, referring to the copy.
|
||||
let copies: Vec<gridcraft_model::DefinedName> = cx
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use gridcraft_core::split_areas;
|
||||
|
||||
use super::{
|
||||
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
|
||||
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
|
||||
@@ -138,7 +140,8 @@ pub(super) struct Model {
|
||||
fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec<CellRef>)> {
|
||||
let mut sheet = None;
|
||||
let mut out = Vec::new();
|
||||
for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) {
|
||||
// A comma inside a quoted sheet name (`'Sales, West'!$A$1`) does not end the area.
|
||||
for part in split_areas(t).into_iter().map(str::trim).filter(|x| !x.is_empty()) {
|
||||
let (sh, body) = split_sheet(part);
|
||||
let si = match sh {
|
||||
Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
|
||||
@@ -163,14 +166,30 @@ fn num_text(t: &str) -> Option<f64> {
|
||||
gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs).
|
||||
/// `t` split at the first `pat` outside quoted sheet names: `'A<=B'!$C$1 <= 5` splits at the
|
||||
/// second `<=`. A doubled quote inside a name (`'O''Brien<=1'!A1`) toggles the quoted state
|
||||
/// twice, as in [`split_areas`], so it doesn't end the name.
|
||||
fn split_unquoted<'a>(t: &'a str, pat: &str) -> Option<(&'a str, &'a str)> {
|
||||
let mut quoted = false;
|
||||
for (i, c) in t.char_indices() {
|
||||
if c == '\'' {
|
||||
quoted = !quoted;
|
||||
} else if !quoted && t.get(i..).is_some_and(|rest| rest.starts_with(pat)) {
|
||||
return Some((t.get(..i)?, t.get(i.saturating_add(pat.len())..)?));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). A relation's characters inside
|
||||
/// a quoted sheet name (`'A<=B'!$C$1 <= 5`) are part of the name.
|
||||
fn split_constraint(t: &str) -> Option<(String, Rel, String)> {
|
||||
for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] {
|
||||
if let Some((a, b)) = t.split_once(op) {
|
||||
if let Some((a, b)) = split_unquoted(t, op) {
|
||||
return Some((a.trim().into(), Rel::parse(op)?, b.trim().into()));
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = t.split_once('=') {
|
||||
if let Some((a, b)) = split_unquoted(t, "=") {
|
||||
return Some((a.trim().into(), Rel::Eq, b.trim().into()));
|
||||
}
|
||||
let lower = t.trim().to_ascii_lowercase();
|
||||
@@ -337,8 +356,8 @@ pub fn sensitivity_applies(p: &Json) -> bool {
|
||||
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
|
||||
fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
|
||||
let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default();
|
||||
cells
|
||||
.split(',')
|
||||
split_areas(cells)
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|x| !x.is_empty())
|
||||
.filter_map(|part| {
|
||||
@@ -446,7 +465,7 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
|
||||
let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
|
||||
let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
|
||||
let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
|
||||
let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",") };
|
||||
let local = |t: String| local_refs(&t);
|
||||
let objective = local(get("solver_opt")?);
|
||||
if objective.trim_matches('"').trim().is_empty() {
|
||||
return None;
|
||||
@@ -555,7 +574,8 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
|
||||
|
||||
/// The cells Save Model writes, top to bottom, as formula text.
|
||||
pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
|
||||
let one_area = |t: &str| !t.contains(',');
|
||||
// A comma inside a quoted sheet name (`'Sales, West'!$A$1`) doesn't start another area.
|
||||
let one_area = |t: &str| split_areas(t).into_iter().filter(|a| !a.trim().is_empty()).count() <= 1;
|
||||
let mut rows = Vec::with_capacity(m.cons.len() + 3);
|
||||
let obj = abs_text(None, &m.objective.a1());
|
||||
rows.push(match m.goal {
|
||||
@@ -565,7 +585,8 @@ pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
|
||||
});
|
||||
rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
|
||||
for c in &m.cons {
|
||||
if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) {
|
||||
// A number on the right side (`1,000`) is no range, whatever its separators.
|
||||
if !one_area(&c.lhs_text) || (matches!(c.rhs, Rhs::Cells(_)) && !one_area(&c.rhs_text)) {
|
||||
return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
|
||||
}
|
||||
let lhs = abs_text(None, &c.lhs_text);
|
||||
@@ -608,7 +629,7 @@ fn call_args(t: &str, name: &str) -> Option<String> {
|
||||
|
||||
/// References without sheet names or `$`.
|
||||
fn local_refs(t: &str) -> String {
|
||||
t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
|
||||
split_areas(t).into_iter().map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
fn not_a_model() -> EngineError {
|
||||
@@ -670,13 +691,13 @@ fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
|
||||
/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
|
||||
fn read_constraint_row(t: String) -> Result<String> {
|
||||
let t = call_args(&t, "AND").unwrap_or(t);
|
||||
if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) {
|
||||
if let Some((x, y)) = split_unquoted(&t, "=INT(").or_else(|| split_unquoted(&t, "=int(")) {
|
||||
if y.strip_suffix(')') != Some(x) {
|
||||
return Err(not_a_model());
|
||||
}
|
||||
return Ok(format!("{} int", local_refs(x)));
|
||||
}
|
||||
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) {
|
||||
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| split_unquoted(x, "=0)+(")).filter(|(a, b)| a == b) {
|
||||
return Ok(format!("{} bin", local_refs(x.0)));
|
||||
}
|
||||
let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
|
||||
@@ -698,7 +719,7 @@ pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
|
||||
} else if let Some(o) = call_args(&first, "MIN") {
|
||||
("min", o, 0.0)
|
||||
} else {
|
||||
let (o, v) = first.split_once('=').ok_or_else(not_a_model)?;
|
||||
let (o, v) = split_unquoted(&first, "=").ok_or_else(not_a_model)?;
|
||||
("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
|
||||
};
|
||||
let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
|
||||
|
||||
@@ -1819,3 +1819,133 @@ fn hostile_models_are_refused_without_panicking() {
|
||||
let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap();
|
||||
assert_ne!(r["status"], "converged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comma_inside_a_quoted_sheet_name_does_not_split_a_reference() {
|
||||
// Wyndor Glass on sheets whose names hold a comma (and, for the second, a doubled quote),
|
||||
// solved from Sheet1 with references that carry those names.
|
||||
for (name, quoted) in [("Sales, West", "'Sales, West'"), ("O'Brien, Jr", "'O''Brien, Jr'")] {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({
|
||||
"objective": format!("{quoted}!$B$1"),
|
||||
"goal": "max",
|
||||
"variables": format!("{quoted}!$A$1,{quoted}!$A$2"),
|
||||
"constraints": [format!("{quoted}!$A$1 <= 4"), format!("{quoted}!$B$2 <= 12"), format!("{quoted}!$B$3 <= 18")],
|
||||
"method": "simplex",
|
||||
}),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
let sheet = &s.doc().unwrap().wb.sheets[1];
|
||||
let v = |a: &str| sheet.value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN);
|
||||
assert!((v("A1") - 2.0).abs() < 1e-9 && (v("A2") - 6.0).abs() < 1e-9, "{name}");
|
||||
assert!((v("B1") - 36.0).abs() < 1e-9, "{name}");
|
||||
// The saved model keeps both variable cells, and loads back as the same model.
|
||||
let model = super::saved_model(&s.doc().unwrap().wb, 1).unwrap_or_else(|| panic!("{name}: no saved model"));
|
||||
assert_eq!(model["objective"], "B1", "{name}: {model}");
|
||||
assert_eq!(model["variables"], "A1,A2", "{name}: {model}");
|
||||
assert_eq!(model["constraints"][0], "A1 <= 4", "{name}: {model}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relation_inside_a_quoted_sheet_name_does_not_split_a_constraint() {
|
||||
// Wyndor Glass with a third, binary variable (Z = 37 at 2, 6, 1) on sheets whose names hold
|
||||
// a relation, solved from Sheet1 with references that carry those names, under every
|
||||
// relation. One name holds a doubled quote as well, and two the text that marks an integer
|
||||
// or a binary constraint in a model written on cells.
|
||||
for name in ["A<=B", "A>=B", "A=B", "Q1=<Q2", "x≤y", "O'Brien<=1", "n=INT(1", "n=0)+(1"] {
|
||||
let q = gridcraft_core::quote_sheet(name);
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0"), ("B1", "=3*A1+5*A2+A3"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let constraints = [
|
||||
format!("{q}!$A$1 <= 4"),
|
||||
format!("{q}!$B$2 <= 12"),
|
||||
format!("{q}!$B$3 <= 18"),
|
||||
format!("{q}!$A$1 >= 1"),
|
||||
format!("{q}!$A$1 = 2"),
|
||||
format!("{q}!$A$1:$A$2 int"),
|
||||
format!("{q}!$A$3 bin"),
|
||||
];
|
||||
let model = json!({"objective": format!("{q}!$B$1"), "goal": "max", "variables": format!("{q}!$A$1:$A$3"), "constraints": constraints, "method": "simplex"});
|
||||
let r = s.execute("data.solver", model).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
assert!((at(&s, name, "B1").as_f64().unwrap_or(f64::NAN) - 37.0).abs() < 1e-9, "{name}");
|
||||
// The saved model holds every constraint whole, and solves again from its sheet.
|
||||
let saved = super::saved_model(&s.doc().unwrap().wb, 1).unwrap_or_else(|| panic!("{name}: no saved model"));
|
||||
assert_eq!(saved["constraints"], json!(["A1 <= 4", "B2 <= 12", "B3 <= 18", "A1 >= 1", "A1 = 2", "A1:A2 int", "A3 bin"]), "{name}: {saved}");
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0")]);
|
||||
let r = s.execute("data.solver", json!({"saved": true})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
assert!((n(&s, "B1") - 37.0).abs() < 1e-9, "{name}");
|
||||
// An integer constraint is still one, so no Sensitivity report is offered.
|
||||
let p = json!({"method": "simplex", "constraints": [format!("{q}!$A$1 int")]});
|
||||
assert!(!super::sensitivity_applies(&p), "{name}");
|
||||
// A model written on cells with references that carry the name loads whole, a Value Of
|
||||
// goal included.
|
||||
let rows = [
|
||||
format!("=MAX({q}!$B$1)"),
|
||||
format!("=COUNT({q}!$A$1:$A$3)"),
|
||||
format!("=AND({q}!$B$3<=18)"),
|
||||
format!("=AND({q}!$A$1:$A$2=INT({q}!$A$1:$A$2))"),
|
||||
format!("=AND(({q}!$A$3=0)+({q}!$A$3=1))"),
|
||||
"={2,1,100,0.075,0,1,0.0001,50,20000,0}".to_string(),
|
||||
];
|
||||
for (k, f) in rows.iter().enumerate() {
|
||||
set(&mut s, &[(&format!("H{}", k + 1), f)]);
|
||||
}
|
||||
let m = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_or_else(|e| panic!("{name}: {e}"))["model"].clone();
|
||||
assert_eq!(m["objective"], "B1", "{name}: {m}");
|
||||
assert_eq!(m["constraints"], json!(["B3 <= 18", "A1:A2 int", "A3 bin"]), "{name}: {m}");
|
||||
set(&mut s, &[("J1", &format!("={q}!$B$1=37")), ("J2", &format!("=COUNT({q}!$A$1:$A$3)")), ("J3", &rows[5])]);
|
||||
let m = s.execute("data.solverLoadModel", json!({"from": "J1"})).unwrap_or_else(|e| panic!("{name}: {e}"))["model"].clone();
|
||||
assert_eq!((m["objective"].as_str(), m["goal"].as_str(), m["value"].as_f64()), (Some("B1"), Some("value"), Some(37.0)), "{name}: {m}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_model_counts_areas_outside_quoted_sheet_names() {
|
||||
// Wyndor Glass on a sheet whose name holds a comma (and, for the second, a doubled quote),
|
||||
// saved on cells with references that carry the name.
|
||||
for name in ["Sales, West", "O'Brien, Jr"] {
|
||||
let q = gridcraft_core::quote_sheet(name);
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2"), ("C3", "18")]);
|
||||
let constraints = [
|
||||
format!("{q}!$A$1 <= 4"),
|
||||
format!("{q}!$B$2 <= 12"),
|
||||
format!("{q}!$B$3 <= {q}!$C$3"),
|
||||
format!("{q}!$A$1:$A$2 int"),
|
||||
// A number with a thousands separator is one value, not two areas.
|
||||
format!("{q}!$B$1 <= 1,000"),
|
||||
];
|
||||
let model = json!({"objective": format!("{q}!$B$1"), "variables": format!("{q}!$A$1:$A$2"), "constraints": constraints, "method": "simplex"});
|
||||
let out = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(out["range"], "H1:H8", "{name}: {out}");
|
||||
let rows: Vec<String> = (3..=7).map(|r| formula(&s, &format!("H{r}"))).collect();
|
||||
assert_eq!(rows, ["AND($A$1<=4)", "AND($B$2<=12)", "AND($B$3<=$C$3)", "AND($A$1:$A$2=INT($A$1:$A$2))", "AND($B$1<=1000)"], "{name}");
|
||||
// The model loads back from the cells and solves.
|
||||
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(loaded["model"]["constraints"], json!(["A1 <= 4", "B2 <= 12", "B3 <= C3", "A1:A2 int", "B1 <= 1000"]), "{name}: {loaded}");
|
||||
let r = s.execute("data.solver", json!({"saved": true})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
assert!((n(&s, "B1") - 36.0).abs() < 1e-9, "{name}");
|
||||
// A side of two areas, each carrying the name, is still refused.
|
||||
let two = json!({"objective": format!("{q}!$B$1"), "variables": format!("{q}!$A$1:$A$2"), "constraints": [format!("{q}!$A$1:$A$2 <= {q}!$C$1,{q}!$C$2")]});
|
||||
let e = s.execute("data.solverSaveModel", json!({"to": "J1", "model": two})).unwrap_err().to_string();
|
||||
assert!(e.contains("one range"), "{name}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
|
||||
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
|
||||
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
|
||||
cmd!(
|
||||
"view.showSheetTabs",
|
||||
"Show Sheet Tabs",
|
||||
[],
|
||||
None,
|
||||
"{on?} (File › Options › Advanced, this workbook; toggles without `on`)",
|
||||
has_doc,
|
||||
|s, p| { workbook_view_flag(s, p, "sheetTabs") }
|
||||
),
|
||||
cmd!(
|
||||
"view.showHorizontalScrollBar",
|
||||
"Show Horizontal Scroll Bar",
|
||||
[],
|
||||
None,
|
||||
"{on?} (this workbook; toggles without `on`)",
|
||||
has_doc,
|
||||
|s, p| { workbook_view_flag(s, p, "horizontal") }
|
||||
),
|
||||
cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| {
|
||||
workbook_view_flag(s, p, "vertical")
|
||||
}),
|
||||
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
|
||||
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
|
||||
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
|
||||
@@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two
|
||||
/// scroll bars, saved with the workbook (`<workbookView showSheetTabs … />`).
|
||||
fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let view = &mut cx.wb.view;
|
||||
let slot = match which {
|
||||
"sheetTabs" => &mut view.show_sheet_tabs,
|
||||
"horizontal" => &mut view.show_horizontal_scroll,
|
||||
_ => &mut view.show_vertical_scroll,
|
||||
};
|
||||
*slot = bool_param(p, "on").unwrap_or(!*slot);
|
||||
Ok(json!({"on": *slot}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
|
||||
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use gridcraft_model::ShowObjects;
|
||||
|
||||
@@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
||||
}
|
||||
first = false;
|
||||
let t = cell_text(wb, sh, CellRef::new(row, col));
|
||||
if t.contains(['\t', '\n', '"']) {
|
||||
// Like Excel, only text with a tab or line break is quoted (its quotes doubled);
|
||||
// `say "hi"` is copied as it is (#2225).
|
||||
if t.contains(['\t', '\n', '\r']) {
|
||||
out.push('"');
|
||||
out.push_str(&t.replace('"', "\"\""));
|
||||
out.push('"');
|
||||
|
||||
+17
-15
@@ -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();
|
||||
@@ -575,20 +588,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
match (unit, d) {
|
||||
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||||
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||||
("weekday", Some(_)) => {
|
||||
let mut n = v;
|
||||
let mut left = step.abs() as i64;
|
||||
while left > 0 {
|
||||
n += step.signum();
|
||||
let Some(day) = datetime_from_serial(sys, n) else {
|
||||
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
|
||||
};
|
||||
if day.weekday != 0 && day.weekday != 6 {
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
// Whole weeks at a time: a huge step costs no more than a small one.
|
||||
("weekday", Some(_)) => add_weekdays(sys, v, step as i64)
|
||||
.ok_or_else(|| crate::EngineError::Other("The weekday series runs past the supported date range.".into()))?,
|
||||
_ => v + step,
|
||||
}
|
||||
}
|
||||
|
||||
+129
-5
@@ -1,4 +1,4 @@
|
||||
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
|
||||
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
mod sylk;
|
||||
|
||||
/// File formats GridCraft reads or writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
@@ -19,6 +21,8 @@ pub enum FileKind {
|
||||
Tsv,
|
||||
Json,
|
||||
Html,
|
||||
/// SYLK symbolic link text (import only).
|
||||
Sylk,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
@@ -33,6 +37,7 @@ impl FileKind {
|
||||
"tsv" | "tab" | "txt" => FileKind::Tsv,
|
||||
"json" | "scjson" => FileKind::Json,
|
||||
"html" | "htm" => FileKind::Html,
|
||||
"slk" => FileKind::Sylk,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -118,12 +123,13 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
|
||||
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
|
||||
/// [`enforce_python_limits`].
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions
|
||||
/// xlsx has no place for, out of their hidden names, and adopts the Python cells they hold (see
|
||||
/// [`adopt_python`]) within the limits of [`enforce_python_limits`].
|
||||
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
|
||||
warnings.extend(crate::macros::load(&mut wb));
|
||||
crate::cmd::controls::load_action_names(&mut wb);
|
||||
warnings.extend(adopt_python(&mut wb));
|
||||
warnings.extend(enforce_python_limits(&mut wb));
|
||||
Ok((wb, warnings))
|
||||
@@ -291,6 +297,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
})?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) {
|
||||
return read;
|
||||
}
|
||||
match kind {
|
||||
Some(FileKind::Json) => {
|
||||
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
|
||||
@@ -310,6 +319,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Sylk) => {
|
||||
let (mut wb, warnings) = sylk::read(name, bytes)?;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -346,9 +360,112 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// How much of a text file is read to tell whether it is a web page.
|
||||
const HTML_SNIFF_BYTES: usize = 64 << 10;
|
||||
|
||||
/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often
|
||||
/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format
|
||||
/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`),
|
||||
/// header cells (`<th>`) are bold and centred, and the sheet takes the name the page gives it
|
||||
/// (`<x:Name>`) or the file's. `None` when the bytes aren't such a page.
|
||||
fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
|
||||
// Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode
|
||||
// the whole file once it is known to be a page within the size a text import takes.
|
||||
if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES {
|
||||
return None;
|
||||
}
|
||||
let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes));
|
||||
// Comments an exporter writes before the page (`<!-- ... -->`) don't make it something else.
|
||||
let mut rest = start.trim_start_matches('\u{feff}').trim_start();
|
||||
while let Some(after) = rest.strip_prefix("<!--") {
|
||||
match after.find("-->").and_then(|end| after.get(end + 3..)) {
|
||||
Some(more) => rest = more.trim_start(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let head: String = rest.chars().take(16).collect::<String>().to_ascii_lowercase();
|
||||
if !["<!doctype html", "<html", "<table", "<head", "<body", "<meta"].iter().any(|t| head.starts_with(t)) {
|
||||
return None;
|
||||
}
|
||||
// Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters.
|
||||
let text = gridcraft_xlsx::decode_text(bytes);
|
||||
let rows = crate::clip_html::table_cells(&text, gridcraft_core::MAX_ROWS as usize, gridcraft_core::MAX_COLS as usize);
|
||||
if rows.iter().all(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
// The cells go through the CSV reader, so numbers, dates and text read exactly as they do there.
|
||||
let mut tsv = String::new();
|
||||
for row in &rows {
|
||||
let fields: Vec<String> = row
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
// The number the page recorded (`x:num`) only when values are read; as text, the
|
||||
// cell keeps the text it shows.
|
||||
let field = match cell.num.filter(|_| !csv_as_text) {
|
||||
Some(n) => gridcraft_core::number_to_text(n),
|
||||
// Whitespace is already collapsed; non-breaking spaces stay, as in Excel.
|
||||
None => cell.text.clone(),
|
||||
};
|
||||
if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field }
|
||||
})
|
||||
.collect();
|
||||
tsv.push_str(&fields.join("\t"));
|
||||
tsv.push('\n');
|
||||
}
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string()));
|
||||
Some(read.map(|(mut wb, report)| {
|
||||
wb.csv_delimiter = None;
|
||||
// Header cells are bold and centred on top of the style the CSV reader gave them, so a
|
||||
// date in a header keeps its date format.
|
||||
let headers: Vec<CellRef> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32)))
|
||||
.collect();
|
||||
let mut bold: std::collections::HashMap<gridcraft_model::StyleId, gridcraft_model::StyleId> = std::collections::HashMap::new();
|
||||
for at in headers {
|
||||
let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue };
|
||||
let id = *bold.entry(base).or_insert_with(|| {
|
||||
wb.styles.derive(base, |st| {
|
||||
st.font.bold = true;
|
||||
st.align.h = gridcraft_model::HAlign::Center;
|
||||
})
|
||||
});
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.set_style(at, id);
|
||||
}
|
||||
}
|
||||
let page_name = find_between(&text, "<x:Name>", "</x:Name>").map(|n| crate::clip_html::normalized(n.trim()));
|
||||
match page_name.filter(|n| !n.is_empty()) {
|
||||
Some(sheet) => set_first_sheet_name(&mut wb, &sheet),
|
||||
None => rename_first_sheet(&mut wb, name),
|
||||
}
|
||||
let mut warnings = report.warnings;
|
||||
if !matches!(kind, Some(FileKind::Html)) {
|
||||
warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes."));
|
||||
}
|
||||
(wb, warnings)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The text between the first `open` and the `close` after it, matched case-insensitively.
|
||||
fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?;
|
||||
let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?;
|
||||
text.get(start..start.checked_add(len)?)
|
||||
}
|
||||
|
||||
/// A CSV opens as a sheet named after the file (like Excel).
|
||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
|
||||
set_first_sheet_name(wb, stem);
|
||||
}
|
||||
|
||||
/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31
|
||||
/// characters; an empty or reserved result keeps the default name.
|
||||
fn set_first_sheet_name(wb: &mut Workbook, stem: &str) {
|
||||
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||
let clean = clean.trim_matches('\'').to_string();
|
||||
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
||||
@@ -377,9 +494,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
match kind {
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
// Macros travel in hidden names.
|
||||
// Macros, and button actions xlsx has no place for, travel in hidden names.
|
||||
FileKind::Xlsx => {
|
||||
let named = crate::macros::with_names(wb)?;
|
||||
let named = match crate::cmd::controls::with_action_names(named.as_ref().unwrap_or(wb))? {
|
||||
Some(w) => Some(w),
|
||||
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)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
@@ -389,6 +510,9 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
}
|
||||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Sylk => {
|
||||
Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into()))
|
||||
}
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
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 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);
|
||||
}
|
||||
"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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=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;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if skipped > 0 {
|
||||
warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped"));
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -604,3 +604,160 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
|
||||
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
|
||||
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_saved_as_xls_opens_its_table() {
|
||||
// #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of
|
||||
// markup. Excel opens the table: values read as typed, <th> bold and centred, <x:Name> names
|
||||
// the sheet.
|
||||
let page = "\u{feff}<html xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><head><meta charset=\"UTF-8\">\
|
||||
<!--[if gte mso 9]><xml><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Orders</x:Name></x:ExcelWorksheet></x:ExcelWorksheets></xml><![endif]-->\
|
||||
</head><body><table><thead><tr><th>Item</th><th>Qty</th><th>Code</th></tr></thead><tbody>\
|
||||
<tr><td>Kiwi &\n Lime</td><td>12</td><td>007</td></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td><td>042</td></tr>\
|
||||
</tbody></table></body></html>";
|
||||
let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap();
|
||||
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.name, "Orders");
|
||||
let v = |a: &str| sh.value(CellRef::parse(a).unwrap());
|
||||
assert_eq!(v("A1"), gridcraft_core::Value::text("Item"));
|
||||
assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime"));
|
||||
assert_eq!(v("B2"), gridcraft_core::Value::number(12.0));
|
||||
assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does");
|
||||
assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded");
|
||||
let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone();
|
||||
assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center);
|
||||
assert!(!style("A2").font.bold);
|
||||
// Opened as text, codes keep their zeros; a page named .html gets no warning.
|
||||
let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007"));
|
||||
// Plain text that merely starts with a tag-like word is still read as CSV.
|
||||
let (wb, _) = open_bytes("notes.csv", b"<b>,1\nx,2\n").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_table_keeps_its_characters_text_and_header_formats() {
|
||||
// A page saved in Windows-1252 keeps its accented letters (it read as replacement characters).
|
||||
let mut page = b"<html><body><table><tr><th>Caf".to_vec();
|
||||
page.push(0xE9);
|
||||
page.extend_from_slice(b"</th><th>2024-01-15</th></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td></tr></table></body></html>");
|
||||
let (wb, _) = open_bytes("menu.xls", &page).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café"));
|
||||
// A date in a header cell stays a date, bold and centred (its format was replaced).
|
||||
let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap()));
|
||||
assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_)));
|
||||
assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center);
|
||||
assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format");
|
||||
assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5));
|
||||
// Opened as text, a cell keeps the text it shows, not the number the page recorded for it.
|
||||
let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() {
|
||||
// An exporter's comment before <html> still makes a web page; ` ` stays a non-breaking
|
||||
// space, as Excel keeps it, while ordinary whitespace collapses.
|
||||
let page = "<!-- exported by a web app -->\n<html><body><table><tr><td>New York</td><td>a \n b</td></tr></table></body></html>";
|
||||
let (wb, warnings) = open_bytes("cities.xls", page.as_bytes()).unwrap();
|
||||
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York"));
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b"));
|
||||
}
|
||||
|
||||
/// A small SYLK file written the way spreadsheets export one: a header, number formats, column
|
||||
/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula.
|
||||
const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\
|
||||
C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\
|
||||
C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\
|
||||
C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\
|
||||
C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\
|
||||
C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\
|
||||
F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n";
|
||||
|
||||
#[test]
|
||||
fn sylk_files_open_with_values_formulas_formats_and_widths() {
|
||||
let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.name, "Parts list", "the sheet is named after the file");
|
||||
let at = |a: &str| CellRef::parse(a).unwrap();
|
||||
let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
|
||||
let text = |s: &str| gridcraft_core::Value::Text(s.into());
|
||||
assert_eq!(sh.value(at("A1")), text("Item"));
|
||||
assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon");
|
||||
assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written");
|
||||
assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0));
|
||||
assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true));
|
||||
assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0));
|
||||
assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached");
|
||||
assert_eq!(formula("D2").as_deref(), Some("B2*C2"));
|
||||
assert_eq!(formula("E2").as_deref(), Some("$B$2+B3"));
|
||||
assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")"));
|
||||
assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)"));
|
||||
assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved");
|
||||
assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column");
|
||||
assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference");
|
||||
// `F;P1;X3` formats the cell on the current row only.
|
||||
let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string();
|
||||
assert_eq!(fmt("C2"), "0.00");
|
||||
assert_eq!(fmt("C3"), "General");
|
||||
assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd");
|
||||
// Widths are in characters, like the Column Width dialog's.
|
||||
let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c)));
|
||||
assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0));
|
||||
assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sylk_r1c1_formulas_become_a1() {
|
||||
let b2 = CellRef::parse("B2").unwrap();
|
||||
for (r1c1, a1) in [
|
||||
("RC", "B2"),
|
||||
("R[1]C[-1]", "A3"),
|
||||
("R1C1:R2C2", "$A$1:$B$2"),
|
||||
("R3", "$3:$3"),
|
||||
("R1:R[2]", "$1:4"),
|
||||
("C[1]", "C:C"),
|
||||
("SUM(C3:C4)", "SUM($C:$D)"),
|
||||
("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"),
|
||||
("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"),
|
||||
("Sheet2!RC", "Sheet2!B2"),
|
||||
("R2D2+rc", "R2D2+B2"),
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sylk_rejects_other_files_and_survives_damage() {
|
||||
assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID");
|
||||
// Positions off the sheet, broken numbers, a missing end and a format past the list.
|
||||
let (wb, warnings) =
|
||||
open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::<Vec<_>>());
|
||||
assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into()));
|
||||
assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sylk_survives_huge_relative_offsets_and_width_floods() {
|
||||
// Relative offsets at the edge of i64 fall off the sheet instead of overflowing.
|
||||
let text = b"ID;P\nC;Y1;X1;K0;ER[9223372036854775807]C\nC;Y2;X1;K0;ER[-9223372036854775808]C[-9223372036854775808]\nE\n";
|
||||
assert!(open_bytes("offsets.slk", text).is_ok());
|
||||
// Width records over every column, many times over: the import keeps a bounded number.
|
||||
let mut flood = b"ID;P\n".to_vec();
|
||||
for _ in 0..5000 {
|
||||
flood.extend_from_slice(b"F;W1 16384 12\n");
|
||||
}
|
||||
flood.extend_from_slice(b"C;Y1;X1;K1\nE\n");
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -514,6 +514,9 @@ pub struct Session {
|
||||
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
|
||||
/// (like Excel's comment indicator option), so it's not saved in the file.
|
||||
pub show_all_notes: bool,
|
||||
/// Whether `link.open` may open web and e-mail addresses without the user's confirmation
|
||||
/// (#2181). Host code sets it; no command does.
|
||||
pub link_policy: links::LinkPolicy,
|
||||
/// Open undo groups, innermost last.
|
||||
groups: Vec<OpenGroup>,
|
||||
next_group: u64,
|
||||
|
||||
@@ -2,8 +2,50 @@
|
||||
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
|
||||
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
|
||||
//! the like are refused. Everything without a scheme stays an in-workbook reference.
|
||||
//!
|
||||
//! Opening an allowed address also needs the user's say-so (#2181): `link.open` is reachable by
|
||||
//! the control channel, the CLI, MCP and workbook scripts, so a prompt-injected agent could open
|
||||
//! `https://attacker/?d=<cell data>`. Under the default [`LinkPolicy::Confirm`] the command refuses
|
||||
//! external addresses; only the host, after showing the address and getting OK from the user,
|
||||
//! opens one through [`crate::Session::open_confirmed_link`], which no command reaches.
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::{EngineError, Result, Session, UiRequest};
|
||||
|
||||
/// Whether `link.open` may hand an external (`http`/`https`/`mailto`) address to the host without
|
||||
/// asking. Set by the host in code, never by a command, so a programmatic caller can't widen it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LinkPolicy {
|
||||
/// `link.open` refuses external addresses; the user opens them by clicking the link, which
|
||||
/// asks first ([`Session::open_confirmed_link`]). References still navigate.
|
||||
#[default]
|
||||
Confirm,
|
||||
/// The host trusts every caller of this session (an embedding that vets requests itself):
|
||||
/// `link.open` opens allowed addresses directly. Disallowed schemes stay refused.
|
||||
Allow,
|
||||
}
|
||||
|
||||
/// The error a programmatic `link.open` of a web or e-mail address returns.
|
||||
pub const NEEDS_CONFIRMATION: &str = "Web and e-mail links open only when the user clicks them and confirms; a command can't open them.";
|
||||
|
||||
impl Session {
|
||||
/// Opens a link the user has just confirmed in the host's "Open Hyperlink" prompt: an allowed
|
||||
/// address goes to the host (`UiRequest::OpenUrl`), a reference selects, anything else is
|
||||
/// refused ([`classify`]). Not a command: only host code that showed the user this exact
|
||||
/// address may call it.
|
||||
pub fn open_confirmed_link(&mut self, target: &str) -> Result<Json> {
|
||||
match classify(target) {
|
||||
LinkKind::Internal(r) => self.execute("edit.goTo", json!({"reference": r.trim_start_matches('#')})),
|
||||
LinkKind::External(u) => {
|
||||
self.ui_requests.push(UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
LinkKind::Refused(why) => Err(EngineError::Other(why)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a link target is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -38,6 +38,7 @@ const DENIED: &[(&str, &str)] = &[
|
||||
("view.newWindow", "macros don't open, hide or show windows"),
|
||||
("view.hideWindow", "macros don't open, hide or show windows"),
|
||||
("view.unhideWindow", "macros don't open, hide or show windows"),
|
||||
("control.press", "macros don't press form controls"),
|
||||
];
|
||||
|
||||
/// Commands under a denied prefix that only change the workbook.
|
||||
@@ -112,6 +113,7 @@ mod tests {
|
||||
"view.newWindow",
|
||||
"view.hideWindow",
|
||||
"view.unhideWindow",
|
||||
"control.press",
|
||||
] {
|
||||
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
|
||||
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
|
||||
|
||||
@@ -23,7 +23,7 @@ use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
|
||||
use gridcraft_model::{Cell, CompareOp, InputKind, MAX_MACRO_REF, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -539,7 +539,8 @@ impl Guard {
|
||||
}
|
||||
objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id)
|
||||
+ deleted(&ss.images, &ps.images, &sa.images, |o| o.id)
|
||||
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id);
|
||||
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id)
|
||||
+ deleted(&ss.controls, &ps.controls, &sa.controls, |o| o.id);
|
||||
}
|
||||
if objects > 0 {
|
||||
parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" }));
|
||||
@@ -1285,7 +1286,12 @@ fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) {
|
||||
let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count();
|
||||
(added, changed, removed)
|
||||
}
|
||||
let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)];
|
||||
let parts = [
|
||||
count(&b.charts, &a.charts, |o| o.id),
|
||||
count(&b.images, &a.images, |o| o.id),
|
||||
count(&b.shapes, &a.shapes, |o| o.id),
|
||||
count(&b.controls, &a.controls, |o| o.id),
|
||||
];
|
||||
parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2))
|
||||
}
|
||||
|
||||
@@ -1393,6 +1399,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result<Begun> {
|
||||
check_params(p, &m)?;
|
||||
let asks = gate(&m, trust)?;
|
||||
approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?;
|
||||
let m = with_output(m, p);
|
||||
let prepared = prepare(s, &m, p.get("inputs"))?;
|
||||
let rule = conflict_rule(p, &m)?;
|
||||
let time_limit_ms = time_limit(p);
|
||||
@@ -1493,9 +1500,21 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `m` with the run's `output` (a form button that sends the output elsewhere) in place of its own
|
||||
/// output anchor, after the policy and the approval, which go by the macro as it is stored. The
|
||||
/// anchor is resolved and checked by [`prepare`] like the macro's own, and the footprint, the
|
||||
/// conflict rule and the per-step guard all work on the cells it names.
|
||||
fn with_output(mut m: Macro, p: &Json) -> Macro {
|
||||
if let Some(o) = p.get("output").and_then(Json::as_str).map(str::trim).filter(|o| !o.is_empty()) {
|
||||
m.output.anchor = Some(o.to_string());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an
|
||||
/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve`
|
||||
/// or `timeLimit` of the wrong kind (`null` counts as not given).
|
||||
/// input given as something other than reference text, `onConflict`, `overwrite`, `approve` or
|
||||
/// `timeLimit` of the wrong kind, and an `output` that isn't reference text of at most
|
||||
/// `MAX_MACRO_REF` characters (`null` counts as not given).
|
||||
pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
|
||||
let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name)));
|
||||
let names = || m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>().join(", ");
|
||||
@@ -1528,6 +1547,12 @@ pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
|
||||
return refuse(format!("`{k}` must be true or false."));
|
||||
}
|
||||
}
|
||||
if let Some(o) = given("output") {
|
||||
let ok = o.as_str().map(str::trim).is_some_and(|t| !t.is_empty() && t.chars().count() <= MAX_MACRO_REF && !t.chars().any(char::is_control));
|
||||
if !ok {
|
||||
return refuse(format!("`output` must be reference text of at most {MAX_MACRO_REF} characters, such as \"F2\" or \"Sheet2!F2\"."));
|
||||
}
|
||||
}
|
||||
if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) {
|
||||
return refuse("`timeLimit` must be a number of seconds above 0.".into());
|
||||
}
|
||||
@@ -1604,7 +1629,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
|
||||
// Nothing from an unapproved macro runs, not even in a dry run.
|
||||
out["needsApproval"] = json!(asks);
|
||||
}
|
||||
let prepared = match prepare(s, &m, p.get("inputs")) {
|
||||
let prepared = match prepare(s, &with_output(m, p), p.get("inputs")) {
|
||||
Ok(pr) => pr,
|
||||
Err(e) => return refused(out, e.to_string()),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,8 @@ struct AggregateInput<'a> {
|
||||
extra: &'a [usize],
|
||||
filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)],
|
||||
filter_fmts: &'a [String],
|
||||
/// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe.
|
||||
filter_sets: &'a [Option<HashSet<String>>],
|
||||
row_fmts: &'a [String],
|
||||
col_fmts: &'a [String],
|
||||
empty: bool,
|
||||
@@ -99,10 +101,10 @@ fn aggregate_record(
|
||||
ri: usize,
|
||||
rec: &[Value],
|
||||
) -> Result<(), String> {
|
||||
for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) {
|
||||
if let Some(selected) = &field.selected {
|
||||
for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) {
|
||||
if let Some(selected) = selected {
|
||||
let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt);
|
||||
if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) {
|
||||
if !selected.contains(&label.to_ascii_lowercase()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -355,15 +357,19 @@ fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>)
|
||||
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 rank = 1u64;
|
||||
for sibling in siblings {
|
||||
let mut path = parent.to_vec();
|
||||
path.push(sibling);
|
||||
if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) {
|
||||
rank += 1;
|
||||
}
|
||||
}
|
||||
Value::number(rank as f64)
|
||||
// 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)
|
||||
}
|
||||
|
||||
fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
|
||||
@@ -471,6 +477,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
|
||||
// ---- aggregate
|
||||
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
|
||||
let filter_sets: Vec<Option<HashSet<String>>> =
|
||||
filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect();
|
||||
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let aggregate = aggregate_source(
|
||||
@@ -484,6 +492,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
extra: &extra,
|
||||
filters: &filters,
|
||||
filter_fmts: &filter_fmts,
|
||||
filter_sets: &filter_sets,
|
||||
row_fmts: &row_fmts,
|
||||
col_fmts: &col_fmts,
|
||||
empty,
|
||||
|
||||
@@ -197,7 +197,8 @@ impl Acc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's caption verb for a function ("Sum of Sales").
|
||||
/// Excel's caption verb for a function ("Sum of Sales"). Count Numbers is captioned "Count of
|
||||
/// Sales", as Excel names it and as the XLSX reader names a value field saved without a name.
|
||||
pub fn func_caption(f: PivotFunc) -> &'static str {
|
||||
match f {
|
||||
PivotFunc::Sum => "Sum",
|
||||
@@ -206,7 +207,7 @@ pub fn func_caption(f: PivotFunc) -> &'static str {
|
||||
PivotFunc::Max => "Max",
|
||||
PivotFunc::Min => "Min",
|
||||
PivotFunc::Product => "Product",
|
||||
PivotFunc::CountNumbers => "Count Numbers",
|
||||
PivotFunc::CountNumbers => "Count",
|
||||
PivotFunc::StdDev => "StdDev",
|
||||
PivotFunc::StdDevP => "StdDevp",
|
||||
PivotFunc::Var => "Var",
|
||||
|
||||
@@ -128,6 +128,32 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of
|
||||
/// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor,
|
||||
/// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that
|
||||
/// isn't a corner the next corner is the top-left one; a single cell stays where it is.
|
||||
pub fn next_corner(&mut self) {
|
||||
self.next_corner_by(Some);
|
||||
}
|
||||
|
||||
/// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the
|
||||
/// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be
|
||||
/// selected (it is skipped). Corners that end up on the active cell are one stop with it.
|
||||
pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option<CellRef>) {
|
||||
let r = self.current();
|
||||
let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)];
|
||||
let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) {
|
||||
Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active),
|
||||
None => corners.iter().find_map(|c| stop(*c)),
|
||||
};
|
||||
if let Some(next) = next {
|
||||
self.active = next;
|
||||
self.anchor = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode).
|
||||
pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
if dr == 0 && dc == 0 {
|
||||
|
||||
+973
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,847 @@
|
||||
//! Form controls: buttons, sliders and spin buttons (insert, format, assign, setValue, press).
|
||||
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_model::{ControlAction, ControlKind, FormControl, MacroInput};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{s, v};
|
||||
use crate::{EngineError, Session, Trust};
|
||||
|
||||
fn id(r: &Json) -> u64 {
|
||||
r["control"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn control(s: &Session, id: u64) -> FormControl {
|
||||
s.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id as u64 == id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().undo.len()
|
||||
}
|
||||
|
||||
fn err(r: crate::Result<Json>) -> String {
|
||||
r.unwrap_err().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_whole_and_each_rule_has_its_own_error() {
|
||||
let mut s = s();
|
||||
let ok = s.execute("insert.slider", json!({"at": "D2", "min": 10, "max": 50, "step": 5, "page": 10})).unwrap();
|
||||
let c = control(&s, id(&ok));
|
||||
assert_eq!((c.kind, c.min, c.max, c.step, c.page, c.value), (ControlKind::Slider, 10, 50, 5, 10, 10));
|
||||
for (p, needle) in [
|
||||
(json!({"min": 2.5}), "`min` must be a whole number from 0 to 30000"),
|
||||
(json!({"min": -1}), "`min` must be a whole number"),
|
||||
(json!({"max": 30001}), "`max` must be a whole number from 0 to 30000"),
|
||||
(json!({"step": "two"}), "`step` must be a whole number"),
|
||||
(json!({"max": 1e300}), "`max` must be a whole number"),
|
||||
(json!({"min": 50, "max": 50}), "Maximum value (50) must be greater than Minimum value (50)"),
|
||||
(json!({"min": 60, "max": 50}), "must be greater than Minimum"),
|
||||
(json!({"step": 0}), "Incremental change must be at least 1"),
|
||||
(json!({"min": 0, "max": 10, "step": 11}), "Incremental change (11) can be at most Maximum minus Minimum (10)"),
|
||||
(json!({"step": 5, "page": 2}), "Page change (2) must be at least the incremental change (5)"),
|
||||
(json!({"link": "not a cell"}), "isn't a cell of this workbook"),
|
||||
(json!({"link": "Nowhere!A1"}), "isn't a cell of this workbook"),
|
||||
(json!({"link": "A1:B2"}), "isn't a cell"),
|
||||
] {
|
||||
let e = err(s.execute("insert.slider", p.clone()));
|
||||
assert!(e.contains(needle), "{p}: {e}");
|
||||
}
|
||||
// A spin button has no page change, so a page below the step is fine.
|
||||
s.execute("insert.spinButton", json!({"step": 5, "page": 2})).unwrap();
|
||||
// Numbers in text are numbers.
|
||||
let n = id(&s.execute("insert.spinButton", json!({"min": "3", "max": "9"})).unwrap());
|
||||
let c = control(&s, n);
|
||||
assert_eq!((c.min, c.max, c.value), (3, 9, 3));
|
||||
// A larger step pulls a slider's default page up with it.
|
||||
let n = id(&s.execute("insert.slider", json!({"max": 1000, "step": 50})).unwrap());
|
||||
let c = control(&s, n);
|
||||
assert_eq!(c.page, 50);
|
||||
// The model checks the same rules.
|
||||
let bad = FormControl { kind: ControlKind::Spin, min: 0, max: 30_001, ..Default::default() };
|
||||
assert!(bad.check_numbers().is_err());
|
||||
assert!(FormControl { kind: ControlKind::Button, max: 0, ..Default::default() }.check_numbers().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_clamps_snaps_and_writes_the_link_as_one_undo_step() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=B1*2"})).unwrap();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1", "min": 10, "max": 100, "step": 5})).unwrap());
|
||||
assert_eq!(control(&s, c).link.as_deref(), Some("$B$1"));
|
||||
let before = undo_len(&s);
|
||||
for (given, want) in [(37.0, 35.0), (38.0, 40.0), (-5.0, 10.0), (1e9, 100.0), (99.0, 100.0), (12.4, 10.0)] {
|
||||
let r = s.execute("control.setValue", json!({"id": c, "value": given})).unwrap();
|
||||
assert_eq!(r["value"], json!(want as u32), "{given}");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(want), "{given}");
|
||||
assert_eq!(v(&s, "C1"), Value::Number(want * 2.0), "dependents recalculate");
|
||||
assert_eq!(control(&s, c).value, want as u32);
|
||||
}
|
||||
assert_eq!(undo_len(&s), before + 5, "one undo step per change (99 snaps to the 100 it holds)");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(100.0));
|
||||
assert_eq!(control(&s, c).value, 100);
|
||||
// Setting the value it already has adds no step.
|
||||
let n = undo_len(&s);
|
||||
s.execute("control.setValue", json!({"id": c, "value": 100})).unwrap();
|
||||
assert_eq!(undo_len(&s), n);
|
||||
for p in [json!({"id": c}), json!({"id": c, "value": "x"}), json!({"id": 999, "value": 1}), json!({"value": 1})] {
|
||||
assert!(s.execute("control.setValue", p).is_err());
|
||||
}
|
||||
// A button has no value.
|
||||
let b = id(&s.execute("insert.button", json!({})).unwrap());
|
||||
assert!(err(s.execute("control.setValue", json!({"id": b, "value": 1}))).contains("control.press"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_linked_cell_moves_the_control() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.spinButton", json!({"link": "$B$2", "max": 20, "step": 2})).unwrap());
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 8, "snapped to the step");
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "500"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 20, "clamped");
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "text"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 20, "text leaves it where it was");
|
||||
// A new control starts at what its cell holds.
|
||||
s.execute("cell.set", json!({"cell": "D4", "input": "12"})).unwrap();
|
||||
let d = id(&s.execute("insert.slider", json!({"link": "D4", "max": 20})).unwrap());
|
||||
assert_eq!(control(&s, d).value, 12);
|
||||
// A linked cell on another sheet.
|
||||
s.execute("home.insertSheet", json!({"name": "Data Sheet"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
let e = id(&s.execute("insert.slider", json!({"link": "'Data Sheet'!a1", "value": 30})).unwrap());
|
||||
assert_eq!(control(&s, e).link.as_deref(), Some("'Data Sheet'!$A$1"));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let other = wb.sheet(wb.sheet_index("Data Sheet").unwrap()).unwrap();
|
||||
assert_eq!(other.cell(gridcraft_core::CellRef::new(0, 0)).unwrap().value, Value::Number(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_replies_with_the_value_a_new_link_gives() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "C5", "input": "30"})).unwrap();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 10})).unwrap());
|
||||
// Relinked to a cell that holds another number, the control takes that number, and the reply
|
||||
// says so, as control.list does.
|
||||
let r = s.execute("control.format", json!({"id": c, "link": "C5"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 30);
|
||||
assert_eq!(r["value"], json!(30), "{r}");
|
||||
assert_eq!(r["link"], json!("$C$5"), "{r}");
|
||||
// A given value is written to the new link and replied as given.
|
||||
let r = s.execute("control.format", json!({"id": c, "link": "D6", "value": 44})).unwrap();
|
||||
assert_eq!((r["value"].clone(), v(&s, "D6")), (json!(44), Value::Number(44.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_follow_inserted_rows() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "E2", "link": "B3", "value": 40})).unwrap());
|
||||
s.execute("selection.set", json!({"range": "1:1"})).unwrap();
|
||||
s.execute("home.insertRows", json!({})).unwrap();
|
||||
let ctl = control(&s, c);
|
||||
assert_eq!(ctl.link.as_deref(), Some("$B$4"));
|
||||
assert_eq!(ctl.anchor.cell.a1(), "E3");
|
||||
assert_eq!(v(&s, "B4"), Value::Number(40.0));
|
||||
s.execute("control.setValue", json!({"id": c, "value": 41})).unwrap();
|
||||
assert_eq!(v(&s, "B4"), Value::Number(41.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_and_assign() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 80})).unwrap());
|
||||
let r = s.execute("control.format", json!({"id": c, "max": 50, "name": "Rate", "horizontal": false})).unwrap();
|
||||
assert_eq!(
|
||||
(r["max"].clone(), r["value"].clone(), r["name"].clone(), r["horizontal"].clone()),
|
||||
(json!(50), json!(50), json!("Rate"), json!(false))
|
||||
);
|
||||
assert_eq!(v(&s, "B1"), Value::Number(50.0), "the new maximum pulls the linked value in");
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "min": 50}))).contains("greater than Minimum"));
|
||||
assert_eq!(control(&s, c).min, 0, "a refused change leaves the control as it was");
|
||||
s.execute("control.format", json!({"id": c, "link": ""})).unwrap();
|
||||
assert_eq!(control(&s, c).link, None);
|
||||
s.execute("control.format", json!({"id": c, "value": 25})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(50.0), "without a link nothing is written");
|
||||
// Buttons: text and action.
|
||||
// A fixed input needs a macro that takes one.
|
||||
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "x"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
|
||||
let b = id(&s.execute("insert.button", json!({"text": "Go", "macro": "Fill", "input": "C2:D4", "output": "F2"})).unwrap());
|
||||
let action = control(&s, b).action;
|
||||
assert_eq!(action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) }));
|
||||
s.execute("control.assign", json!({"id": b, "command": "home.bold", "params": {"range": "A1"}})).unwrap();
|
||||
assert!(matches!(control(&s, b).action, Some(ControlAction::Command { ref id, .. }) if id == "home.bold"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "file.saveAs"}))).contains("can't run file.saveAs"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "control.press", "params": {"id": b}}))).contains("can't run control.press"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "no.such"}))).contains("no command"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "macro": "Fill", "input": "nonsense!!"}))).contains("`input`"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b}))).contains("clear"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": c, "macro": "Fill"}))).contains("only buttons"));
|
||||
s.execute("control.assign", json!({"id": b, "clear": true})).unwrap();
|
||||
assert_eq!(control(&s, b).action, None);
|
||||
// Renaming the macro keeps the button pointing at it.
|
||||
s.execute("control.assign", json!({"id": b, "macro": "fill"})).unwrap();
|
||||
s.execute("macro.rename", json!({"name": "Fill", "to": "Fill Block"})).unwrap();
|
||||
assert!(matches!(control(&s, b).action, Some(ControlAction::Macro { ref name, .. }) if name == "Fill Block"));
|
||||
let list = s.execute("control.list", json!({})).unwrap();
|
||||
assert_eq!(list.as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(list[1]["text"], json!("Go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_a_button_runs_its_macro_as_one_undo_step() {
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"macro.save",
|
||||
json!({"name": "Mark", "inputs": [{"name": "Input", "kind": "range"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "done"}}], "output": {"onConflict": "overwrite"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let b = id(&s.execute("insert.button", json!({"macro": "Mark"})).unwrap());
|
||||
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let r = s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!(r["macro"], json!("Mark"));
|
||||
assert_eq!(v(&s, "C3"), Value::text("done"));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C3"), Value::Empty);
|
||||
// A fixed input ignores the selection.
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "F6:G7"})).unwrap();
|
||||
s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!((v(&s, "F6"), v(&s, "C3")), (Value::text("done"), Value::Empty));
|
||||
// No action, a missing macro, not a button.
|
||||
let empty = id(&s.execute("insert.button", json!({"text": "Nothing"})).unwrap());
|
||||
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("has no action"));
|
||||
s.execute("control.assign", json!({"id": empty, "macro": "Gone"})).unwrap();
|
||||
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("no macro called"));
|
||||
let spin = id(&s.execute("insert.spinButton", json!({})).unwrap());
|
||||
assert!(err(s.execute("control.press", json!({"id": spin}))).contains("only buttons"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_button_goes_through_the_policy_with_the_documents_trust() {
|
||||
let mut s = s();
|
||||
let b = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "7"}})).unwrap());
|
||||
// A workbook made here runs it straight away, as one undo step.
|
||||
let before = undo_len(&s);
|
||||
s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(7.0));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// From a file: it asks first, once.
|
||||
s.doc_mut().unwrap().trust = Trust::FromFile;
|
||||
match s.execute("control.press", json!({"id": b})) {
|
||||
Err(EngineError::Approve(q)) => assert!(q.contains("came with a workbook you opened"), "{q}"),
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
assert_eq!(v(&s, "A1"), Value::Empty, "nothing ran");
|
||||
s.execute("control.press", json!({"id": b, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(7.0));
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": ""})).unwrap();
|
||||
s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(7.0), "approved for the session");
|
||||
// A command that reads a file always asks.
|
||||
s.doc_mut().unwrap().trust = Trust::Local;
|
||||
let f = id(&s.execute("insert.button", json!({"command": "data.fromTextCsv", "params": {"path": "/nonexistent/x.csv"}})).unwrap());
|
||||
assert!(matches!(s.execute("control.press", json!({"id": f})), Err(EngineError::Approve(q)) if q.contains("reads a file")));
|
||||
// A denied command that a file brought in never runs.
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == f) {
|
||||
c.action = Some(ControlAction::Command { id: "file.saveAs".into(), params: json!({"path": "/tmp/x.xlsx"}) });
|
||||
}
|
||||
assert!(err(s.execute("control.press", json!({"id": f, "approve": true}))).contains("can't run file.saveAs"));
|
||||
// A macro button in a file's workbook asks too (the runner's gate).
|
||||
s.execute(
|
||||
"macro.save",
|
||||
json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}], "output": {"onConflict": "overwrite"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let m = id(&s.execute("insert.button", json!({"macro": "M"})).unwrap());
|
||||
s.doc_mut().unwrap().trust = Trust::FromFile;
|
||||
assert!(matches!(s.execute("control.press", json!({"id": m})), Err(EngineError::Approve(_))));
|
||||
s.execute("control.press", json!({"id": m, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_commands_approval_question_names_the_file() {
|
||||
let mut s = s();
|
||||
let b = id(&s.execute("insert.button", json!({"command": "file.exportCsv", "params": {"path": "/tmp/reports/q3 totals.csv"}})).unwrap());
|
||||
match s.execute("control.press", json!({"id": b})) {
|
||||
Err(EngineError::Approve(q)) => assert!(q.contains("writes a file (/tmp/reports/q3 totals.csv)"), "{q}"),
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
// A long path is shortened in the middle, and line breaks can't hide part of it.
|
||||
let long = format!("/tmp/{}\n{}.csv", "a".repeat(300), "b".repeat(300));
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == b) {
|
||||
c.action = Some(ControlAction::Command { id: "file.exportCsv".into(), params: json!({"path": long}) });
|
||||
}
|
||||
match s.execute("control.press", json!({"id": b})) {
|
||||
Err(EngineError::Approve(q)) => {
|
||||
assert!(q.contains("(/tmp/aaa") && q.contains("…") && q.contains("bbb.csv)") && !q.contains('\n') && q.len() < 600, "{q}")
|
||||
}
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
// Without a path the question is unchanged.
|
||||
let plain = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "1"}})).unwrap());
|
||||
s.doc_mut().unwrap().trust = Trust::FromFile;
|
||||
match s.execute("control.press", json!({"id": plain})) {
|
||||
Err(EngineError::Approve(q)) => assert!(q.ends_with("came with a workbook you opened."), "{q}"),
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_refused() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"width": 1e30, "height": -5, "at": "XFD1048576"})).unwrap());
|
||||
let a = control(&s, c).anchor;
|
||||
assert_eq!((a.width, a.height), (10_000.0, 4.0));
|
||||
let long = "x".repeat(2_000);
|
||||
assert!(s.execute("insert.button", json!({"text": long})).is_err());
|
||||
assert!(s.execute("insert.button", json!({"text": 5})).is_err());
|
||||
assert!(s.execute("insert.button", json!({"command": "cell.set", "params": [1]})).is_err());
|
||||
assert!(s.execute("control.format", json!({"id": c, "link": 5})).is_err());
|
||||
assert!(s.execute("control.format", json!({"id": c, "value": f64::MAX})).is_err());
|
||||
assert!(s.execute("control.setValue", json!({"id": c, "value": f64::MIN})).is_ok());
|
||||
assert_eq!(control(&s, c).value, 0);
|
||||
// Snapping keeps the maximum reachable when the step doesn't divide the range.
|
||||
let odd = FormControl { kind: ControlKind::Spin, min: 0, max: 10, step: 3, ..Default::default() };
|
||||
assert_eq!((odd.snap(9.4), odd.snap(9.6), odd.snap(f64::NAN)), (9, 10, 0));
|
||||
}
|
||||
|
||||
fn link(s: &Session, sheet_i: usize, id: u64) -> String {
|
||||
let sh = s.doc().unwrap().wb.sheet(sheet_i).unwrap();
|
||||
sh.controls.iter().find(|c| c.id as u64 == id).and_then(|c| c.link.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_follow_inserts_deletes_renames_and_cut_moves() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
// A control on Data pointing back at Sheet1.
|
||||
let back = id(&s.execute("insert.spinButton", json!({"at": "C3", "link": "Sheet1!$B$2"})).unwrap());
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let local = id(&s.execute("insert.slider", json!({"at": "E2", "link": "$B$2"})).unwrap());
|
||||
let remote = id(&s.execute("insert.spinButton", json!({"at": "E6", "link": "Data!$A$1"})).unwrap());
|
||||
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap();
|
||||
assert_eq!(link(&s, 0, local), "$C$3");
|
||||
assert_eq!(link(&s, 1, back), "Sheet1!$C$3");
|
||||
assert_eq!(link(&s, 0, remote), "Data!$A$1", "edits on Sheet1 leave links into Data alone");
|
||||
|
||||
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
|
||||
s.execute("edit.cut", json!({"range": "C3"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "G9"})).unwrap();
|
||||
assert_eq!(link(&s, 0, local), "$G$9", "a cut-move takes the link with the cell");
|
||||
assert_eq!(link(&s, 1, back), "Sheet1!$G$9");
|
||||
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
|
||||
assert_eq!(link(&s, 0, remote), "Inputs!$A$1");
|
||||
s.execute("home.deleteRows", json!({"rows": "9:9"})).unwrap();
|
||||
assert!(link(&s, 0, local).contains("#REF!"), "{}", link(&s, 0, local));
|
||||
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
|
||||
assert!(link(&s, 0, remote).contains("#REF!"), "{}", link(&s, 0, remote));
|
||||
// A link to a deleted cell writes nothing.
|
||||
s.execute("control.setValue", json!({"id": local, "value": 5})).unwrap();
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!((link(&s, 0, local), link(&s, 0, remote)), ("$G$9".to_string(), "Inputs!$A$1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_object_commands_leave_a_locked_control_on_a_protected_sheet_alone() {
|
||||
let mut s = s();
|
||||
let locked = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1"})).unwrap());
|
||||
let other = id(&s.execute("insert.spinButton", json!({"at": "D8"})).unwrap());
|
||||
let free = id(&s.execute("insert.button", json!({"at": "D12"})).unwrap());
|
||||
s.execute("control.format", json!({"id": free, "locked": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for (cmd, p) in [
|
||||
("object.move", json!({"kind": "control", "id": locked, "at": "H9"})),
|
||||
("object.delete", json!({"kind": "control", "id": locked})),
|
||||
("object.setAnchorMode", json!({"kind": "control", "id": locked, "mode": "absolute"})),
|
||||
("arrange.bringForward", json!({"kind": "control", "id": locked, "toFront": true})),
|
||||
("arrange.sendBackward", json!({"kind": "control", "id": locked})),
|
||||
("arrange.align", json!({"kind": "control", "ids": [locked, other], "align": "left"})),
|
||||
] {
|
||||
assert!(err(s.execute(cmd, p)).contains("protected"), "{cmd}");
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before), "nothing changed");
|
||||
// An unlocked control still moves, as in Excel.
|
||||
s.execute("object.move", json!({"kind": "control", "id": free, "at": "H12"})).unwrap();
|
||||
assert_eq!(control(&s, free).anchor.cell.a1(), "H12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_control_on_a_protected_sheet_cannot_be_changed() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1"})).unwrap());
|
||||
let b = id(&s.execute("insert.button", json!({})).unwrap());
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "max": 50}))).contains("protected"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "home.bold"}))).contains("protected"));
|
||||
}
|
||||
|
||||
fn reopen(s: &mut Session) {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_button_action_comes_back_only_on_the_same_button() {
|
||||
use crate::cmd::controls::{load_action_names, with_action_names};
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D6", "text": "Other"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D9", "text": "Fixed", "macro": "Mark", "input": "F1:G2"})).unwrap();
|
||||
let wb = (*s.doc().unwrap().wb).clone();
|
||||
let stored = with_action_names(&wb).unwrap().unwrap();
|
||||
let actions = |wb: &gridcraft_model::Workbook| wb.sheet(0).unwrap().controls.iter().map(|c| c.action.clone()).collect::<Vec<_>>();
|
||||
let kept = |wb: &gridcraft_model::Workbook| {
|
||||
let mut wb = wb.clone();
|
||||
for c in &mut wb.sheet_mut(0).unwrap().controls {
|
||||
// As xlsx carries them: a macro on the selection, or nothing for a command.
|
||||
c.action = match c.action.take() {
|
||||
Some(ControlAction::Macro { name, .. }) => Some(ControlAction::Macro { name, input: MacroInput::Selection, output: None }),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
wb
|
||||
};
|
||||
// The same buttons in the same places get their actions back.
|
||||
let mut same = kept(&stored);
|
||||
load_action_names(&mut same);
|
||||
assert_eq!(actions(&same), actions(&wb));
|
||||
// Another application removed the first button: the command must not move to "Other".
|
||||
let mut shifted = kept(&stored);
|
||||
shifted.sheet_mut(0).unwrap().controls.remove(0);
|
||||
load_action_names(&mut shifted);
|
||||
assert_eq!(shifted.sheets[0].controls[0].action, None, "the command stays with the removed button");
|
||||
// A button given another macro there keeps it; a command doesn't replace a macro either.
|
||||
let mut reassigned = kept(&stored);
|
||||
for i in [0, 2] {
|
||||
reassigned.sheet_mut(0).unwrap().controls[i].action =
|
||||
Some(ControlAction::Macro { name: "Other".into(), input: MacroInput::Selection, output: None });
|
||||
}
|
||||
load_action_names(&mut reassigned);
|
||||
for i in [0, 2] {
|
||||
assert!(
|
||||
matches!(&reassigned.sheets[0].controls[i].action, Some(ControlAction::Macro { name, input: MacroInput::Selection, .. }) if name == "Other"),
|
||||
"{i}"
|
||||
);
|
||||
}
|
||||
// Another application cleared the macro of "Fixed": the stored action (with its fixed input)
|
||||
// does not bring the assignment back. The command on a button given no macro still does.
|
||||
let mut cleared = kept(&stored);
|
||||
cleared.sheet_mut(0).unwrap().controls[2].action = None;
|
||||
load_action_names(&mut cleared);
|
||||
assert_eq!(cleared.sheets[0].controls[2].action, None, "the removed assignment stays removed");
|
||||
assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back");
|
||||
}
|
||||
|
||||
/// An entry of the hidden name must carry the button's name and anchor cell (the writer always
|
||||
/// stores both): a hand-edited entry without either, or with something else there, is dropped
|
||||
/// like an unparsable one, so it cannot pin its action onto whatever button sits at that index.
|
||||
#[test]
|
||||
fn a_kept_action_without_the_buttons_name_or_anchor_is_dropped() {
|
||||
use crate::cmd::controls::{ACTIONS_KEY, load_action_names, with_action_names};
|
||||
use crate::cmd::stash;
|
||||
let mut s = s();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
let stored = with_action_names(&s.doc().unwrap().wb).unwrap().unwrap();
|
||||
let items = stash::get_json(&stored, ACTIONS_KEY).unwrap();
|
||||
assert!(items[0]["name"].is_string() && items[0]["at"].is_string(), "the writer stores both keys: {items}");
|
||||
// As the file brings a command button back: with no action.
|
||||
let kept = |items: &Json| {
|
||||
let mut wb = stored.clone();
|
||||
wb.sheet_mut(0).unwrap().controls[0].action = None;
|
||||
stash::remove(&mut wb, ACTIONS_KEY);
|
||||
stash::set_json(&mut wb, ACTIONS_KEY, items).unwrap();
|
||||
load_action_names(&mut wb);
|
||||
assert!(stash::get(&wb, ACTIONS_KEY).is_none(), "the hidden name is removed");
|
||||
wb.sheets[0].controls[0].action.clone()
|
||||
};
|
||||
let edited = |key: &str, value: Option<Json>| {
|
||||
let mut items = items.clone();
|
||||
let entry = items[0].as_object_mut().unwrap();
|
||||
match value {
|
||||
Some(v) => entry.insert(key.to_string(), v),
|
||||
None => entry.remove(key),
|
||||
};
|
||||
items
|
||||
};
|
||||
assert_eq!(kept(&edited("name", None)), None, "no `name`");
|
||||
assert_eq!(kept(&edited("at", None)), None, "no `at`");
|
||||
assert_eq!(kept(&edited("name", Some(json!(1)))), None, "`name` is not text");
|
||||
assert_eq!(kept(&edited("at", Some(Json::Null))), None, "`at` is null");
|
||||
assert!(matches!(kept(&items), Some(ControlAction::Command { .. })), "the writer's own entry still comes back");
|
||||
}
|
||||
|
||||
/// A fixed input range for a macro that declares no inputs would fail every press (`macro.run`
|
||||
/// refuses inputs the macro doesn't take), so the assignment is refused when it is made, naming
|
||||
/// the macro; a macro the workbook doesn't have yet is not checked, and a macro with an input
|
||||
/// takes the range. A file that brings such a stored input in gets the macro on the selection.
|
||||
#[test]
|
||||
fn a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned() {
|
||||
use crate::cmd::controls::{load_action_names, with_action_names};
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
|
||||
let want = "macro 'Fill' takes no inputs";
|
||||
assert!(err(s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "input": "C2:D4"}))).contains(want));
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 0, "no button was placed");
|
||||
let b = id(&s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "output": "F2"})).unwrap());
|
||||
let e = err(s.execute("control.assign", json!({"id": b, "macro": "fill", "input": "C2:D4"})));
|
||||
assert!(e.contains(want), "any letter case: {e}");
|
||||
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) }));
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "C2:D4"})).unwrap();
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Later", "input": "C2:D4"})).unwrap();
|
||||
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Later".into(), input: MacroInput::Fixed("C2:D4".into()), output: None }));
|
||||
// A file's stored fixed input for Fill (another version assigned it) comes back as the selection.
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Fill", "output": "F2"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
wb.sheet_mut(0).unwrap().controls[0].action =
|
||||
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) });
|
||||
let mut stored = with_action_names(&wb).unwrap().unwrap();
|
||||
load_action_names(&mut stored);
|
||||
assert_eq!(
|
||||
stored.sheets[0].controls[0].action,
|
||||
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) })
|
||||
);
|
||||
}
|
||||
|
||||
/// The saved xlsx bytes of `s`'s workbook.
|
||||
fn saved_xlsx(s: &mut Session) -> Vec<u8> {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `bytes` with `edit` applied to the text of every part, as another application that rewrote
|
||||
/// those parts would leave the file.
|
||||
fn edit_parts(bytes: &[u8], mut edit: impl FnMut(&str, String) -> String) -> Vec<u8> {
|
||||
use std::io::{Cursor, Read, Write};
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut part = zip.by_index(i).unwrap();
|
||||
let name = part.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
part.read_to_end(&mut data).unwrap();
|
||||
let data = match String::from_utf8(data) {
|
||||
Ok(text) => edit(&name, text).into_bytes(),
|
||||
Err(e) => e.into_bytes(),
|
||||
};
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Opens `bytes` as `name`, checks the hidden name of the button actions is taken back out, and
|
||||
/// lists the controls.
|
||||
fn open_and_list(s: &mut Session, name: &str, bytes: &[u8]) -> Json {
|
||||
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "{name}: the hidden name is taken back out");
|
||||
s.execute("control.list", json!({})).unwrap()
|
||||
}
|
||||
|
||||
/// The macro reference xlsx writes for a button that runs `Mark`.
|
||||
const MARK: &str = "[0]!Mark";
|
||||
|
||||
/// Another application removed a button's macro assignment and kept the button with its name and
|
||||
/// place: the hidden name's copy of the action (with the fixed input and the output cell) does not
|
||||
/// bring the assignment back. The same file, unedited, reopens with the whole action.
|
||||
#[test]
|
||||
fn a_macro_assignment_removed_in_another_application_stays_removed() {
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
|
||||
let before = s.execute("control.list", json!({})).unwrap()[0].clone();
|
||||
let full = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Fixed("F1:G2".into()), output: Some("H1".into()) };
|
||||
assert_eq!(before["action"], serde_json::to_value(&full).unwrap());
|
||||
let bytes = saved_xlsx(&mut s);
|
||||
// xlsx assigns the macro in the sheet's `<controlPr macro>` and in the VML shape's
|
||||
// `<x:FmlaMacro>` (the parts the reader takes it from); clearing the assignment drops both.
|
||||
let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("<x:FmlaMacro>{MARK}</x:FmlaMacro>"));
|
||||
let mut edited = Vec::new();
|
||||
let cleared = edit_parts(&bytes, |name, text| {
|
||||
if text.contains(&attr) || text.contains(&fmla) {
|
||||
edited.push(name.to_string());
|
||||
}
|
||||
text.replace(&attr, "").replace(&fmla, "")
|
||||
});
|
||||
edited.sort();
|
||||
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
|
||||
let list = open_and_list(&mut s, "cleared.xlsx", &cleared);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
for key in ["kind", "name", "at", "text"] {
|
||||
assert_eq!(list[0][key], before[key], "{key}: the same button");
|
||||
}
|
||||
assert_eq!(list[0].get("action"), None, "the removed assignment stays removed");
|
||||
// Unedited, the file still assigns the macro, and the action comes back whole.
|
||||
let list = open_and_list(&mut s, "kept.xlsx", &bytes);
|
||||
assert_eq!(list[0]["action"], before["action"]);
|
||||
}
|
||||
|
||||
/// Another application assigned a macro to a button that ran a command (kept in the hidden name):
|
||||
/// the file's macro wins, as it does when a macro button is given another macro.
|
||||
#[test]
|
||||
fn a_macro_assigned_in_another_application_replaces_a_stored_command() {
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
let bytes = saved_xlsx(&mut s);
|
||||
let (pr, data) = ("<controlPr", "<x:ClientData ObjectType=\"Button\">");
|
||||
let mut edited = Vec::new();
|
||||
let assigned = edit_parts(&bytes, |name, text| {
|
||||
if text.contains(pr) || text.contains(data) {
|
||||
edited.push(name.to_string());
|
||||
}
|
||||
text.replace(pr, &format!("<controlPr macro=\"{MARK}\"")).replace(data, &format!("{data}<x:FmlaMacro>{MARK}</x:FmlaMacro>"))
|
||||
});
|
||||
edited.sort();
|
||||
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
|
||||
let list = open_and_list(&mut s, "assigned.xlsx", &assigned);
|
||||
let plain = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Selection, output: None };
|
||||
assert_eq!(list[0]["action"], serde_json::to_value(&plain).unwrap(), "the file's macro, not the stored command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_and_action_survives_xlsx() {
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
|
||||
s.execute("insert.slider", json!({"at": "D2", "link": "B1", "min": 10, "max": 200, "step": 10, "page": 50, "value": 70})).unwrap();
|
||||
s.execute("insert.spinButton", json!({"at": "D6", "link": "B2", "max": 9, "value": 4})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D9", "text": "Plain", "macro": "Mark"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D11", "text": "Fixed", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D13", "text": "Command", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "a note"})).unwrap();
|
||||
let before: Vec<Json> = s
|
||||
.execute("control.list", json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
c.as_object_mut().unwrap().remove("id");
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
reopen(&mut s);
|
||||
let after: Vec<Json> = s
|
||||
.execute("control.list", json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
c.as_object_mut().unwrap().remove("id");
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (b, a) in before.iter().zip(&after) {
|
||||
for key in ["kind", "name", "at", "text", "link", "value", "min", "max", "step", "page", "horizontal", "action", "print", "locked"] {
|
||||
assert_eq!(b.get(key), a.get(key), "{key}: {b} vs {a}");
|
||||
}
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "the hidden name is taken back out");
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
// A workbook from a file: its command button asks before it runs.
|
||||
let cmd = after.iter().position(|c| c["text"] == json!("Command")).unwrap();
|
||||
let id = s.execute("control.list", json!({})).unwrap()[cmd]["id"].as_u64().unwrap();
|
||||
assert!(matches!(s.execute("control.press", json!({"id": id})), Err(EngineError::Approve(_))));
|
||||
s.execute("control.press", json!({"id": id, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "A9"), Value::text("cmd"));
|
||||
// The linked cells still move the controls.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "120"})).unwrap();
|
||||
assert_eq!(s.execute("control.list", json!({})).unwrap()[0]["value"], json!(120));
|
||||
}
|
||||
|
||||
/// A button's command goes through the macro policy when it is assigned and again when it is
|
||||
/// pressed, so a file whose hidden name carries a denied command (written by another application,
|
||||
/// or edited by hand) can't use a button as a way around the policy.
|
||||
#[test]
|
||||
fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-control-deny-{}", std::process::id()));
|
||||
let leak = dir.join("leak.xlsx").to_string_lossy().to_string();
|
||||
let denied = [
|
||||
("link.open", json!({"url": "https://example.com/?d=secret"})),
|
||||
("file.saveAs", json!({"path": leak})),
|
||||
("file.save", json!({})),
|
||||
("file.open", json!({"path": leak})),
|
||||
("app.commands", json!({})),
|
||||
("macro.run", json!({"name": "Mark"})),
|
||||
("automate.runScript", json!({"name": "Leak", "trusted": true})),
|
||||
("control.press", json!({"id": 1})),
|
||||
];
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
|
||||
// Neither Insert › Button nor Assign Action takes one.
|
||||
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Run"})).unwrap());
|
||||
for (cmd, params) in &denied {
|
||||
let want = format!("can't run {cmd}");
|
||||
assert!(err(s.execute("insert.button", json!({"at": "B9", "command": cmd, "params": params}))).contains(&want), "{cmd}");
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": cmd, "params": params}))).contains(&want), "{cmd}");
|
||||
assert_eq!(control(&s, b).action, None, "{cmd}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1, "no button was placed");
|
||||
// A file can still carry them: one button per command, given its action as a file would.
|
||||
for i in 1..denied.len() {
|
||||
s.execute("insert.button", json!({"at": format!("B{}", 2 + 3 * i), "text": format!("Run {i}")})).unwrap();
|
||||
}
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
for (c, (cmd, params)) in sh.controls.iter_mut().zip(&denied) {
|
||||
c.action = Some(ControlAction::Command { id: (*cmd).to_string(), params: params.clone() });
|
||||
}
|
||||
reopen(&mut s);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
let list = s.execute("control.list", json!({})).unwrap();
|
||||
for (i, (cmd, params)) in denied.iter().enumerate() {
|
||||
// The action came back through the hidden name, so the check at press time is what holds.
|
||||
assert_eq!(list[i]["action"], json!({"type": "command", "id": cmd, "params": params}), "{cmd}");
|
||||
let pressed = list[i]["id"].as_u64().unwrap();
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("control.press", json!({"id": pressed, "approve": true}))).contains(&format!("can't run {cmd}")), "{cmd}");
|
||||
assert_eq!(undo_len(&s), before, "{cmd}: nothing ran");
|
||||
}
|
||||
assert!(!std::path::Path::new(&leak).exists());
|
||||
assert_eq!(v(&s, "A1"), Value::Empty, "the macro never ran");
|
||||
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "no page was opened: {:?}", s.ui_requests);
|
||||
}
|
||||
|
||||
/// A command the policy allows may still turn out to open a dialog or a web page when it runs
|
||||
/// (a command given no parameters opens its dialog): the press is refused with the reason, as a
|
||||
/// macro step is, and the request is dropped, so nothing opens. Messages for the person pass.
|
||||
#[test]
|
||||
fn a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs() {
|
||||
use crate::UiRequest;
|
||||
use crate::cmd::controls::take_requests;
|
||||
let mut s = s();
|
||||
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Link", "command": "insert.link", "params": {}})).unwrap());
|
||||
let before = undo_len(&s);
|
||||
let name = control(&s, b).display_name();
|
||||
assert_eq!(
|
||||
err(s.execute("control.press", json!({"id": b}))),
|
||||
format!("'{name}' would open the insertLink dialog; give its command the parameters it needs.")
|
||||
);
|
||||
assert!(!s.ui_requests.iter().any(|r| matches!(r, UiRequest::Dialog(..))), "{:?}", s.ui_requests);
|
||||
assert_eq!(undo_len(&s), before, "nothing stays done");
|
||||
// The filter itself, with every kind of request a command can queue: the page is refused,
|
||||
// the cell edit is dropped, the message stays, and requests queued before are left alone.
|
||||
s.ui_requests.push(UiRequest::Message("earlier".into()));
|
||||
let queued = s.ui_requests.len();
|
||||
s.ui_requests.extend([UiRequest::EditCell(None), UiRequest::Message("saved".into()), UiRequest::OpenUrl("https://example.com/".into())]);
|
||||
assert_eq!(take_requests(&mut s, queued, "Go").as_deref(), Some("'Go' would open a web page, which buttons don't do."));
|
||||
assert_eq!(s.ui_requests, vec![UiRequest::Message("earlier".into()), UiRequest::Message("saved".into())]);
|
||||
assert_eq!(take_requests(&mut s, 5, "Go"), None, "a mark past the queue's end drops nothing");
|
||||
assert_eq!(s.ui_requests.len(), 2);
|
||||
}
|
||||
|
||||
/// A linked cell that holds a formula: a write of the value the formula already shows is skipped,
|
||||
/// so Format Control's OK (which sends the value back every time) keeps the formula, also on a
|
||||
/// protected sheet with the cell locked; a changed value replaces the formula with the constant.
|
||||
#[test]
|
||||
fn a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
let formula =
|
||||
|s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).is_some_and(|c| c.formula.is_some());
|
||||
assert!(formula(&s));
|
||||
// Inserting with the value the formula shows (10 * 2 = 20) writes nothing.
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 20})).unwrap());
|
||||
assert!(formula(&s), "the formula stays");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(20.0));
|
||||
// The dialog's OK: every number sent back, the value unchanged, a new name.
|
||||
s.execute("control.format", json!({"id": c, "name": "Speed", "value": 20, "min": 0, "max": 100, "step": 1, "page": 10, "locked": false}))
|
||||
.unwrap();
|
||||
assert!(formula(&s), "a rename keeps the formula");
|
||||
// Editing A1 moves the control through the formula (15 * 2 = 30); OK with that value keeps it.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "15"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 30);
|
||||
s.execute("control.format", json!({"id": c, "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
|
||||
assert!(formula(&s));
|
||||
// On a protected sheet with B1 locked, the same OK is not refused.
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("control.format", json!({"id": c, "name": "Rate", "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
|
||||
assert_eq!(control(&s, c).name, "Rate");
|
||||
assert!(formula(&s));
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
// A changed value writes the constant over the formula.
|
||||
s.execute("control.format", json!({"id": c, "value": 40, "min": 0, "max": 100, "step": 1})).unwrap();
|
||||
assert!(!formula(&s), "the formula is replaced");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(40.0));
|
||||
}
|
||||
|
||||
/// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's
|
||||
/// protection governs it: a locked cell on a protected sheet refuses the change, and the control
|
||||
/// keeps its value (the control's change and the cell write are one group, rolled back together).
|
||||
/// A control's own Locked flag keeps it from being moved or edited, not from being used, as in
|
||||
/// Excel, so an unlocked linked cell takes the value on a protected sheet.
|
||||
#[test]
|
||||
fn a_locked_linked_cell_on_a_protected_sheet_refuses_the_change_and_the_control_keeps_its_value() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 40})).unwrap());
|
||||
s.execute("control.format", json!({"id": c, "locked": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 60}))).contains("protected"));
|
||||
assert_eq!((control(&s, c).value, v(&s, "B1")), (40, Value::Number(40.0)), "nothing is half-applied");
|
||||
assert_eq!(undo_len(&s), before, "no undo step is left behind");
|
||||
// Format Control: a new maximum that would pull the value into the cell is refused whole.
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "max": 30}))).contains("protected"));
|
||||
assert_eq!((control(&s, c).max, control(&s, c).value), (100, 40));
|
||||
// Inserting a control with a value for a locked cell is refused and leaves no control behind.
|
||||
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "B2", "value": 5}))).contains("protected"));
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
|
||||
// The value the cell already holds writes nothing, so nothing is refused.
|
||||
s.execute("control.setValue", json!({"id": c, "value": 40})).unwrap();
|
||||
assert_eq!(undo_len(&s), before);
|
||||
// An unlocked linked cell takes the value on the protected sheet.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("control.setValue", json!({"id": c, "value": 60})).unwrap();
|
||||
assert_eq!((control(&s, c).value, v(&s, "B1")), (60, Value::Number(60.0)));
|
||||
}
|
||||
|
||||
/// A link to a cell on another sheet obeys that sheet's protection, not the control's sheet's.
|
||||
#[test]
|
||||
fn a_link_to_another_sheet_obeys_that_sheets_protection() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "Data!B1", "value": 10})).unwrap());
|
||||
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
|
||||
let data = |s: &Session| {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index("Data").unwrap()).unwrap().value(gridcraft_core::CellRef::new(0, 1))
|
||||
};
|
||||
assert_eq!(data(&s), Value::Number(10.0));
|
||||
s.execute("review.protectSheet", json!({"sheet": "Data"})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 20}))).contains("protected"));
|
||||
assert_eq!((control(&s, c).value, data(&s)), (10, Value::Number(10.0)));
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "Data!B2", "value": 5}))).contains("protected"));
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
|
||||
// Protecting the control's own sheet instead leaves the write to the other sheet alone.
|
||||
s.execute("review.unprotectSheet", json!({"sheet": "Data"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("control.setValue", json!({"id": c, "value": 20})).unwrap();
|
||||
assert_eq!((control(&s, c).value, data(&s)), (20, Value::Number(20.0)));
|
||||
// A link to a sheet the workbook doesn't have is refused with a clear message.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "link": "Nowhere!B1"}))).contains("isn't a cell of this workbook"));
|
||||
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
|
||||
}
|
||||
@@ -2434,3 +2434,83 @@ fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() {
|
||||
assert!(r.get("shortcutDropped").is_none(), "{r}");
|
||||
assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q");
|
||||
}
|
||||
|
||||
/// `output` (a form button's output cell) moves where the macro writes: the footprint, the
|
||||
/// conflict rule and `macro.check` protect the data at the cells it names, as at the macro's own
|
||||
/// anchor, and a value that names no cell is refused before anything runs.
|
||||
#[test]
|
||||
fn an_output_override_is_checked_and_protected_like_the_macros_own_anchor() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Placed", "output": {"anchor": "D1"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Output", "input": "out"}}
|
||||
]}),
|
||||
);
|
||||
set(&mut s, "F1", "keep me");
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
// Writing over F1 asks (programmatic runs fail) and changes nothing, as it would at D1.
|
||||
let r = s.execute("macro.run", json!({"name": "Placed", "output": "F1"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert!(q.contains("(F1 on Sheet1)"), "{q}");
|
||||
let err = s.execute("macro.run", json!({"name": "Placed", "output": "F1", "onConflict": "fail"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Nothing was changed"), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start));
|
||||
// macro.check reports the override and its conflict.
|
||||
let c = s.execute("macro.check", json!({"name": "Placed", "output": "F1"})).unwrap();
|
||||
assert_eq!((&c["ok"], &c["output"], &c["conflicts"]), (&json!(false), &json!("F1"), &json!(1)), "{c}");
|
||||
// On another sheet too.
|
||||
s.execute("cell.set", json!({"cell": "Other!B2", "input": "theirs"})).unwrap();
|
||||
let r = s.execute("macro.run", json!({"name": "Placed", "output": "Other!B2"}));
|
||||
assert!(matches!(&r, Err(EngineError::Confirm(q)) if q.contains("(B2 on Other)")), "{r:?}");
|
||||
// The stored anchor holding data doesn't matter when the output goes elsewhere.
|
||||
set(&mut s, "D1", "stored anchor's data");
|
||||
s.execute("macro.run", json!({"name": "Placed", "output": "H1"})).unwrap();
|
||||
assert_eq!((v(&s, "H1").display(), v(&s, "D1").display()), ("out".to_string(), "stored anchor's data".to_string()));
|
||||
// Hostile values are refused before anything runs.
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for (bad, needle) in [
|
||||
(json!(5), "`output` must be reference text"),
|
||||
(json!({"cell": "F1"}), "`output` must be reference text"),
|
||||
(json!(" "), "`output` must be reference text"),
|
||||
(json!("x".repeat(1_025)), "`output` must be reference text"),
|
||||
(json!("F1\nF2"), "`output` must be reference text"),
|
||||
(json!("NoSuchSheet!A1"), "output anchor"),
|
||||
(json!("XFE1"), "output anchor"),
|
||||
(json!("A1048577"), "output anchor"),
|
||||
(json!("not a cell!!"), "output anchor"),
|
||||
] {
|
||||
let err = s.execute("macro.run", json!({"name": "Placed", "output": bad, "onConflict": "overwrite"})).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{bad}: {err}");
|
||||
let c = s.execute("macro.check", json!({"name": "Placed", "output": bad})).unwrap();
|
||||
assert!(c["errors"].to_string().contains(needle), "{bad}: {c}");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
}
|
||||
|
||||
/// After a partial footprint (Solver can't be previewed), a later step that deletes a form
|
||||
/// control is caught by the check of each step, as one that deletes a chart or a picture is.
|
||||
#[test]
|
||||
fn a_step_that_deletes_a_form_control_after_a_partial_footprint_is_caught() {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "1");
|
||||
set(&mut s, "C1", "=A1");
|
||||
let control = s.execute("insert.spinButton", json!({"at": "E2"})).unwrap()["control"].as_u64().unwrap();
|
||||
let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}});
|
||||
let delete = json!({"command": "object.delete", "params": {"kind": "control", "id": control}});
|
||||
put(&mut s, json!({"name": "Drop", "inputs": [{"name": "Input"}], "steps": [solver, delete]}));
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
for rule in ["fail", "ask"] {
|
||||
let r = s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "onConflict": rule}));
|
||||
let e = match (rule, r) {
|
||||
("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e,
|
||||
(_, r) => panic!("{rule}: {r:?}"),
|
||||
};
|
||||
assert!(e.contains("Step 2 (object.delete): It would write over data (1 object)"), "{e}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{rule}: rolled back");
|
||||
}
|
||||
s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "overwrite": true})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().controls.is_empty());
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
|
||||
/// slicer and an equation, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
/// slicer, an equation and a form control, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 6] {
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
|
||||
@@ -30,12 +30,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
|
||||
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
|
||||
let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control");
|
||||
[
|
||||
ObjRef::new(ObjKind::Chart, chart),
|
||||
ObjRef::new(ObjKind::Image, image),
|
||||
ObjRef::new(ObjKind::Shape, shape),
|
||||
ObjRef::new(ObjKind::Slicer, slicer),
|
||||
ObjRef::new(ObjKind::Equation, equation),
|
||||
ObjRef::new(ObjKind::Control, control),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,9 +70,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
|
||||
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
|
||||
}
|
||||
}
|
||||
// One undo step per delete brings the equation back.
|
||||
// One undo step per delete brings the control, deleted last, back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(sheet(&s, 0).has_object(objs[4]));
|
||||
assert!(sheet(&s, 0).has_object(objs[5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,9 +86,11 @@ fn selection_pane_lists_and_stacks_slicers() {
|
||||
for k in ObjKind::ALL {
|
||||
assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}");
|
||||
}
|
||||
assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top");
|
||||
assert_eq!(kinds.last(), Some(&"control"), "form controls stack on top");
|
||||
assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes");
|
||||
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
|
||||
assert!(names.contains(&format!("Slider {}", objs[5].id).as_str()), "{names:?}");
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
|
||||
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
|
||||
@@ -122,17 +126,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
|
||||
fn every_kind_follows_row_and_column_edits_and_sorts() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18.
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18, slider P18.
|
||||
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
|
||||
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
|
||||
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]);
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20", "Q20"]);
|
||||
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
|
||||
// Sorting moves a picture anchored inside the sorted block with its row.
|
||||
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
|
||||
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
|
||||
@@ -261,6 +265,7 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
|
||||
.chain(sh.images.iter().map(|c| c.id))
|
||||
.chain(sh.shapes.iter().map(|c| c.id))
|
||||
.chain(sh.slicers.iter().map(|c| c.id))
|
||||
.chain(sh.controls.iter().map(|c| c.id))
|
||||
.collect()
|
||||
};
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
|
||||
@@ -314,6 +319,26 @@ fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
|
||||
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
|
||||
}
|
||||
|
||||
/// A copied sheet's form controls take free ids like its other objects: a file's control can
|
||||
/// carry the largest id, and the ids the copy asks for count the controls.
|
||||
#[test]
|
||||
fn a_copied_sheets_form_controls_take_free_ids_too() {
|
||||
let mut s = s();
|
||||
let slider = id_of(&s.execute("insert.slider", json!({"at": "B2", "link": "$A$1"})).unwrap(), "control");
|
||||
s.execute("insert.button", json!({"at": "B6"})).unwrap();
|
||||
s.execute("insert.shape", json!({"kind": "rectangle", "at": "D2"})).unwrap();
|
||||
// The file's slider carries the largest id (set on the model, as a loaded file would).
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().controls.iter_mut().find(|o| o.id == slider).unwrap().id = u32::MAX;
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.controls.iter().map(|o| o.id).chain(sh.shapes.iter().map(|o| o.id))).collect();
|
||||
assert_eq!(ids.len(), 6);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6, "every control and shape id is distinct across both sheets: {ids:?}");
|
||||
assert_eq!(ids.iter().filter(|&&i| i == u32::MAX).count(), 1, "only the file's slider keeps the largest id");
|
||||
}
|
||||
|
||||
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
|
||||
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
|
||||
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Ctrl/Cmd+Shift+arrow searches the active cell's column (or row) from the moving edge (#3484).
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn table() -> Session {
|
||||
// Headers in A2:K2, a key column in A3:A13, nothing else filled.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let header: Vec<i64> = (1..=11).collect();
|
||||
s.execute("range.setValues", json!({"range": "A2:K2", "values": [header]})).unwrap();
|
||||
let keys: Vec<Vec<i64>> = (1..=11).map(|i| vec![i]).collect();
|
||||
s.execute("range.setValues", json!({"range": "A3:A13", "values": keys})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ctrl_shift(s: &mut Session, dr: i64, dc: i64) -> String {
|
||||
s.execute("selection.move", json!({"dr": dr, "dc": dc, "extend": true, "jump": true})).unwrap();
|
||||
s.doc().unwrap().selection.a1()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_down_after_ctrl_shift_right_follows_the_active_column() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, 0, 1), "A2:K2");
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K13", "column A's data ends on row 13; column K's ends on row 2");
|
||||
// From the end of column A's data the next press runs to the sheet's last row.
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K1048576");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_right_after_ctrl_shift_down_follows_the_active_row() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:A13");
|
||||
assert_eq!(ctrl_shift(&mut s, 0, 1), "A2:K13", "row 2's data ends in column K; row 13's ends in column A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_down_after_ctrl_shift_left_follows_the_active_column() {
|
||||
// The active cell is on the right: column K, empty below the header, decides, not column A.
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "K2"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, 0, -1), "A2:K2");
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K1048576");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_right_after_ctrl_shift_up_follows_the_active_row() {
|
||||
// The active cell is at the bottom: row 13, empty right of the key, decides, not row 2.
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "A13"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, -1, 0), "A2:A13");
|
||||
assert_eq!(ctrl_shift(&mut s, 0, 1), "2:13", "row 13's data ends in column A, so the rows extend to the last column");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod chart_workbook_names;
|
||||
mod command_sequence_fuzz;
|
||||
mod cross_sheet_cut;
|
||||
mod csv_export_budget;
|
||||
mod ctrl_shift_arrow_active_line;
|
||||
mod custom_filter_blanks;
|
||||
mod custom_view_freeze;
|
||||
mod dynamicmetadata;
|
||||
@@ -28,6 +29,7 @@ mod sheet_copy_spill;
|
||||
mod sheet_name_quoting;
|
||||
mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod structural_edit_rewrite;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod trace_error;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Structural edits (insert and delete of lines, sheet rename and delete) rewrite only the formulas
|
||||
//! whose tree they change. A formula they leave alone keeps its stored text and its flags.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) {
|
||||
s.execute(command, params).unwrap();
|
||||
}
|
||||
|
||||
fn workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Other"}));
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Spare"}));
|
||||
run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn formula(s: &Session, sheet: &str, cell: &str) -> Arc<Formula> {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.clone().unwrap()
|
||||
}
|
||||
|
||||
/// Stores a formula with this exact text and no cached tree, as a JSON workbook loads it.
|
||||
fn put_raw(s: &mut Session, cell: &str, text: &str) {
|
||||
let wb = Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let i = wb.sheet_index("Sheet1").unwrap();
|
||||
let f = Formula { text: text.to_string(), expr: None, array: None, dynamic: false };
|
||||
wb.sheet_mut(i).unwrap().cells.set(CellRef::parse(cell).unwrap(), Cell::formula(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_leaves_a_formula_alone_keeps_its_stored_text() {
|
||||
// The rewrite compares trees, not printed text, so a stored non-canonical text is not
|
||||
// re-printed by an edit that does not change the formula. An edit that changes it prints it.
|
||||
let mut s = workbook();
|
||||
put_raw(&mut s, "C1", "sum(a1, Other!b2)");
|
||||
run(&mut s, "home.insertRows", json!({"rows": "5:5"}));
|
||||
run(&mut s, "home.insertColumns", json!({"cols": "F:F"}));
|
||||
run(&mut s, "sheet.rename", json!({"sheet": "Spare", "name": "Spare2"}));
|
||||
run(&mut s, "home.deleteSheet", json!({"sheet": "Spare2"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").text, "sum(a1, Other!b2)");
|
||||
|
||||
run(&mut s, "home.insertRows", json!({"rows": "1:1"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C2").text, "SUM(A2,Other!B2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_referenced_sheet_rewrites_the_formula() {
|
||||
let mut s = workbook();
|
||||
put_raw(&mut s, "C1", "sum(a1, Other!b2)");
|
||||
run(&mut s, "sheet.rename", json!({"sheet": "Other", "name": "Data"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").text, "SUM(A1,Data!B2)");
|
||||
run(&mut s, "home.deleteSheet", json!({"sheet": "Data"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").text, "SUM(A1,#REF!)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_rows_above_a_legacy_array_moves_its_range_when_the_formula_is_unchanged() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "range.setValues", json!({"range": "B1", "values": [[1], [2]]}));
|
||||
run(&mut s, "selection.set", json!({"range": "C5:C6"}));
|
||||
run(&mut s, "cell.set", json!({"cell": "C5", "input": "=B1:B2*2", "array": true}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C5").array, Some(RangeRef::parse("C5:C6").unwrap()));
|
||||
run(&mut s, "home.insertRows", json!({"rows": "3:3"}));
|
||||
let f = formula(&s, "Sheet1", "C6");
|
||||
assert_eq!(f.text, "B1:B2*2");
|
||||
assert_eq!(f.array, Some(RangeRef::parse("C6:C7").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_unrelated_sheet_keeps_legacy_arrays() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "range.setValues", json!({"range": "B1", "values": [[1], [2]]}));
|
||||
run(&mut s, "selection.set", json!({"range": "C1:C2"}));
|
||||
run(&mut s, "cell.set", json!({"cell": "C1", "input": "=B1:B2*2", "array": true}));
|
||||
run(&mut s, "home.deleteSheet", json!({"sheet": "Spare"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").array, Some(RangeRef::parse("C1:C2").unwrap()));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
|
||||
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => match shift_ref(&r, dr, dc) {
|
||||
Expr::Ref(r) => match shift_ref(r, dr, dc) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
@@ -60,14 +60,14 @@ fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option<u32> {
|
||||
if (0..max as i64).contains(&n) { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option<Reference> {
|
||||
fn shift_ref(r: Reference, dr: i64, dc: i64) -> Option<Reference> {
|
||||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?),
|
||||
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1),
|
||||
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
Some(Reference { sheet: r.sheet, kind })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -95,27 +95,54 @@ pub enum Edit {
|
||||
MoveToSheet { from: RangeRef, to_sheet: String, to_row: u32, to_col: u32 },
|
||||
}
|
||||
|
||||
/// Whether `r`, in a formula living on sheet `host`, points into sheet `target`.
|
||||
fn on_target(r: &Reference, host: &str, target: &str) -> bool {
|
||||
match &r.sheet {
|
||||
SheetSel::Current => host.eq_ignore_ascii_case(target),
|
||||
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
|
||||
SheetSel::Span(..) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`adjust`] would change `r`. It runs the same [`adjust_ref`] on a copy without the
|
||||
/// sheet name, so no name is copied: the adjustment reads only the position, and the sheet a cut
|
||||
/// to another sheet assigns is compared afterwards.
|
||||
fn ref_changes(r: &Reference, edit: &Edit) -> bool {
|
||||
let probe = Reference { sheet: SheetSel::Current, kind: r.kind.clone() };
|
||||
match adjust_ref(probe, edit) {
|
||||
None => true,
|
||||
Some(p) => p.kind != r.kind || (p.sheet != SheetSel::Current && p.sheet != r.sheet),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`adjust`] would change the formula `e` at all, without copying or rebuilding it.
|
||||
fn changes(e: &Expr, host: &str, target: &str, edit: &Edit) -> bool {
|
||||
let mut changed = false;
|
||||
e.walk(&mut |x| {
|
||||
if !changed && let Expr::Ref(r) = x {
|
||||
changed = on_target(r, host, target) && ref_changes(r, edit);
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// Adjusts a formula living on sheet `host` after `edit` on sheet `target`.
|
||||
pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => {
|
||||
let on_target = match &r.sheet {
|
||||
SheetSel::Current => host.eq_ignore_ascii_case(target),
|
||||
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
|
||||
SheetSel::Span(..) => false,
|
||||
};
|
||||
if !on_target {
|
||||
return Expr::Ref(r);
|
||||
}
|
||||
match adjust_ref(&r, edit) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
}
|
||||
}
|
||||
Expr::Ref(r) if on_target(&r, host, target) => match adjust_ref(r, edit) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`adjust`] for a borrowed formula: `None` when the edit leaves it exactly as it is, so a
|
||||
/// caller can skip unaffected formulas without copying, rebuilding or printing them.
|
||||
pub fn adjust_if_changed(e: &Expr, host: &str, target: &str, edit: &Edit) -> Option<Expr> {
|
||||
changes(e, host, target, edit).then(|| adjust(e.clone(), host, target, edit))
|
||||
}
|
||||
|
||||
/// New position of an index after an insert (`None` never) or delete (`None` = deleted).
|
||||
fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<u32> {
|
||||
if insert {
|
||||
@@ -168,7 +195,7 @@ fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
fn adjust_ref(r: Reference, edit: &Edit) -> Option<Reference> {
|
||||
match edit {
|
||||
Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => {
|
||||
let insert = matches!(edit, Edit::Insert { .. });
|
||||
@@ -193,7 +220,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
}
|
||||
(k, _) => k.clone(),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
Some(Reference { sheet: r.sheet, kind })
|
||||
}
|
||||
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
|
||||
// Like inserting/deleting lines, for references that lie within the shifted
|
||||
@@ -204,7 +231,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
|
||||
};
|
||||
if !inside {
|
||||
return Some(r.clone());
|
||||
return Some(r);
|
||||
}
|
||||
let axis = *axis;
|
||||
let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
@@ -215,7 +242,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
let range = r.range();
|
||||
if !from.contains_range(&range) {
|
||||
return Some(r.clone());
|
||||
return Some(r);
|
||||
}
|
||||
let mv = |a: &Anchor| -> Option<Anchor> {
|
||||
let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?;
|
||||
@@ -233,11 +260,11 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
|
||||
}
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
Some(Reference { sheet: r.sheet, kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
return Some(r);
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
moved.sheet = SheetSel::Named(to_sheet.clone());
|
||||
|
||||
@@ -61,7 +61,7 @@ impl FormulaLocale {
|
||||
pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {
|
||||
let mut expr = parser::parse_syntax(src, locale)?;
|
||||
if locale == FormulaLocale::Es {
|
||||
normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;
|
||||
normalize(&mut expr, &known_name, &mut Bindings::default(), 0, false)?;
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)
|
||||
return Ok(crate::print(expr));
|
||||
}
|
||||
let mut expr = expr.clone();
|
||||
normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?;
|
||||
normalize(&mut expr, &known_name, &mut Bindings::default(), 0, true)?;
|
||||
let canonical = crate::print(&expr);
|
||||
let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let mut out = String::new();
|
||||
@@ -131,11 +131,47 @@ pub(crate) fn boolean_alias(name: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {
|
||||
/// The LET/LAMBDA names in scope, with how many times each is bound, so a lookup is one probe
|
||||
/// instead of a scan of every binding for each call.
|
||||
#[derive(Default)]
|
||||
struct Bindings {
|
||||
stack: Vec<String>,
|
||||
counts: std::collections::HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Bindings {
|
||||
fn len(&self) -> usize {
|
||||
self.stack.len()
|
||||
}
|
||||
|
||||
fn push(&mut self, name: &str) {
|
||||
let key = crate::fold_name(name);
|
||||
*self.counts.entry(key.clone()).or_default() += 1;
|
||||
self.stack.push(key);
|
||||
}
|
||||
|
||||
fn truncate(&mut self, len: usize) {
|
||||
while self.stack.len() > len {
|
||||
let Some(key) = self.stack.pop() else { break };
|
||||
if let Some(n) = self.counts.get_mut(&key) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.counts.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, name: &str) -> bool {
|
||||
self.counts.contains_key(&crate::fold_name(name))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Bindings, depth: usize, display: bool) -> Result<(), ParseError> {
|
||||
if depth > parser::MAX_DEPTH {
|
||||
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
|
||||
}
|
||||
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name);
|
||||
let bound = |name: &str, bindings: &Bindings| bindings.contains(name) || known_name(name);
|
||||
match expr {
|
||||
Expr::Bool(value) if display => {
|
||||
let alias = if *value { "VERDADERO" } else { "FALSO" };
|
||||
@@ -156,7 +192,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
|
||||
// The new name is not in scope in its own value expression.
|
||||
normalize(value, known_name, bindings, depth + 1, display)?;
|
||||
if let Expr::Name(name) = declaration {
|
||||
bindings.push(name.clone());
|
||||
bindings.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +205,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
|
||||
if let Some((body, params)) = args.split_last_mut() {
|
||||
for param in params {
|
||||
if let Expr::Name(name) = param {
|
||||
bindings.push(name.clone());
|
||||
bindings.push(name);
|
||||
}
|
||||
}
|
||||
normalize(body, known_name, bindings, depth + 1, display)?;
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! `adjust`, `adjust_if_changed` and `shift_relative` against a verbatim copy of the previous
|
||||
//! implementation, which cloned and rebuilt every formula. The rewrite must give the same tree
|
||||
//! (and so the same text) for every formula and edit, and `adjust_if_changed` must say
|
||||
//! "unchanged" exactly when the old code returned the formula as it was.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit, adjust, adjust_if_changed, shift_relative};
|
||||
use gridcraft_formula::{Expr, parse, print};
|
||||
|
||||
/// The implementation before the borrowed check, kept as the test oracle.
|
||||
mod old {
|
||||
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_formula::{Anchor, Expr, RefKind, Reference, SheetSel};
|
||||
|
||||
fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> {
|
||||
if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None }
|
||||
}
|
||||
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => match shift_ref(&r, dr, dc) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option<Anchor> {
|
||||
let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr };
|
||||
let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc };
|
||||
let (row, col) = clamp_anchor(row, col)?;
|
||||
Some(Anchor { row, col, ..*a })
|
||||
}
|
||||
|
||||
fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option<u32> {
|
||||
if abs {
|
||||
return Some(v);
|
||||
}
|
||||
let n = v as i64 + d;
|
||||
if (0..max as i64).contains(&n) { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option<Reference> {
|
||||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?),
|
||||
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1),
|
||||
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
|
||||
pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => {
|
||||
let on_target = match &r.sheet {
|
||||
SheetSel::Current => host.eq_ignore_ascii_case(target),
|
||||
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
|
||||
SheetSel::Span(..) => false,
|
||||
};
|
||||
if !on_target {
|
||||
return Expr::Ref(r);
|
||||
}
|
||||
match adjust_ref(&r, edit) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<u32> {
|
||||
if insert {
|
||||
if v >= at {
|
||||
let n = v as u64 + count as u64;
|
||||
if n >= max as u64 { None } else { Some(n as u32) }
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
} else if v < at {
|
||||
Some(v)
|
||||
} else if v >= at.saturating_add(count) {
|
||||
Some(v - count)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<(u32, u32)> {
|
||||
if insert {
|
||||
let lo2 = if lo >= at { lo as u64 + count as u64 } else { lo as u64 };
|
||||
let hi2 = if hi >= at { hi as u64 + count as u64 } else { hi as u64 };
|
||||
let hi2 = hi2.min(max as u64 - 1);
|
||||
if lo2 > hi2 {
|
||||
return None;
|
||||
}
|
||||
Some((lo2 as u32, hi2 as u32))
|
||||
} else {
|
||||
let end = at.saturating_add(count);
|
||||
if lo >= at && hi < end {
|
||||
return None;
|
||||
}
|
||||
let lo2 = if lo < at {
|
||||
lo
|
||||
} else if lo >= end {
|
||||
lo - count
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let hi2 = if hi < at {
|
||||
hi
|
||||
} else if hi >= end {
|
||||
hi - count
|
||||
} else {
|
||||
at.saturating_sub(1)
|
||||
};
|
||||
Some((lo2, hi2.max(lo2)))
|
||||
}
|
||||
}
|
||||
|
||||
fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
match edit {
|
||||
Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => {
|
||||
let insert = matches!(edit, Edit::Insert { .. });
|
||||
let kind = match (&r.kind, axis) {
|
||||
(RefKind::Cell(a), Axis::Rows) => RefKind::Cell(Anchor { row: map_index(a.row, *at, *count, insert, MAX_ROWS)?, ..*a }),
|
||||
(RefKind::Cell(a), Axis::Cols) => RefKind::Cell(Anchor { col: map_index(a.col, *at, *count, insert, MAX_COLS)?, ..*a }),
|
||||
(RefKind::Range(a, b), Axis::Rows) => {
|
||||
let (lo, hi) = map_span(a.row.min(b.row), a.row.max(b.row), *at, *count, insert, MAX_ROWS)?;
|
||||
RefKind::Range(Anchor { row: lo, ..*a }, Anchor { row: hi, ..*b })
|
||||
}
|
||||
(RefKind::Range(a, b), Axis::Cols) => {
|
||||
let (lo, hi) = map_span(a.col.min(b.col), a.col.max(b.col), *at, *count, insert, MAX_COLS)?;
|
||||
RefKind::Range(Anchor { col: lo, ..*a }, Anchor { col: hi, ..*b })
|
||||
}
|
||||
(RefKind::Rows(r0, a0, r1, a1), Axis::Rows) => {
|
||||
let (lo, hi) = map_span(*r0.min(r1), *r0.max(r1), *at, *count, insert, MAX_ROWS)?;
|
||||
RefKind::Rows(lo, *a0, hi, *a1)
|
||||
}
|
||||
(RefKind::Cols(c0, a0, c1, a1), Axis::Cols) => {
|
||||
let (lo, hi) = map_span(*c0.min(c1), *c0.max(c1), *at, *count, insert, MAX_COLS)?;
|
||||
RefKind::Cols(lo, *a0, hi, *a1)
|
||||
}
|
||||
(k, _) => k.clone(),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
|
||||
let span = r.range();
|
||||
let (inside, at, count) = match axis {
|
||||
Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()),
|
||||
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
|
||||
};
|
||||
if !inside {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let axis = *axis;
|
||||
let lines =
|
||||
if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
adjust_ref(r, &lines)
|
||||
}
|
||||
Edit::Move { from, to_row, to_col } => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
let range = r.range();
|
||||
if !from.contains_range(&range) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mv = |a: &Anchor| -> Option<Anchor> {
|
||||
let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?;
|
||||
Some(Anchor { row, col, ..*a })
|
||||
};
|
||||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(mv(a)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?),
|
||||
RefKind::Rows(a, aa, b, ba) => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
|
||||
}
|
||||
RefKind::Cols(a, aa, b, ba) => {
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
|
||||
}
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
moved.sheet = SheetSel::Named(to_sheet.clone());
|
||||
Some(moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formulas with every reference shape the adjustment distinguishes.
|
||||
const FORMULAS: &[&str] = &[
|
||||
"A1",
|
||||
"B2+C3*D4",
|
||||
"$B$2+B$2+$B2",
|
||||
"SUM(A1:A10)+SUM(B2:C3)+SUM($B$2:$C$3)",
|
||||
"SUM(C3:B2)",
|
||||
"SUM(A:A)+SUM($B:$C)+SUM(2:2)+SUM($3:$5)",
|
||||
"SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)+SUM(A1.:B1)",
|
||||
"S!B2+s!C3+'S'!A1:B9",
|
||||
"Other!B2+Other!A:A+Other!2:3",
|
||||
"'My Sheet'!B2+'my sheet'!C3:D4",
|
||||
"Dest!B2+S!B2",
|
||||
"SUM(S:Other!B2)+SUM(Other:S!A1:C3)",
|
||||
"XFD1048576+XFD1+A1048576+SUM(XFC1048575:XFD1048576)",
|
||||
"SUM(1048576:1048576)+SUM(XFD:XFD)",
|
||||
"VLOOKUP($B2,Lookup!$A$1:$D$500,3,FALSE)",
|
||||
"IF(AND(C2>0,D2<100),C2*1.07,IF(E2=\"x\",\"yes\",\"no\"))",
|
||||
"Table1[@Qty]*Table1[@Price]+SUM(Table1[[#Data],[Qty]])",
|
||||
"SUMPRODUCT(A2:C2,{1,2,3})",
|
||||
"LET(x,A2*2,y,B2+x,x*y)",
|
||||
"LAMBDA(x,x+B2)(C3)",
|
||||
"IFERROR(INDEX(Lookup!$A:$A,MATCH(F2,Lookup!$B:$B,0)),\"\")",
|
||||
"-B2%+(C3)^2&\"a\"",
|
||||
"A1#+S!B2#",
|
||||
"S!MyName+MyName+#REF!+S!#REF!",
|
||||
"COUNTIFS(Data!$A:$A,A2,Data!$C:$C,\">=\"&$G$2)",
|
||||
"1+2",
|
||||
"\"text\"",
|
||||
"B2:C3 B3:D4",
|
||||
"(B2:C3,E5)",
|
||||
"INDIRECT(\"B2\")+OFFSET(B2,1,1)",
|
||||
// From the parser edge cases (prefixes and LET parameter names).
|
||||
"LET(_xlpm.TRUE,1,_xlpm.TRUE+1)",
|
||||
"LET(_xlpm.A1,1,_xlpm.A1)",
|
||||
"LAMBDA(_xlpm.x,_xlpm.x+1)(2)",
|
||||
"_xlfn.LET(_xlpm.R1C1,1,_xlpm.R1C1*2)",
|
||||
"LET(_xlpm.FALSE,2,_xlpm.FALSE)",
|
||||
"_xlpm.SUM(1)",
|
||||
"LET(_xlpm.x_1,1,_xlpm.x_1)",
|
||||
"_xlfn.ANCHORARRAY(A1)+_xlfn.SINGLE(B1:B3)",
|
||||
"SUM(_xlfn._TRO_ALL(A1:E10))",
|
||||
];
|
||||
|
||||
fn col(c: u32) -> String {
|
||||
gridcraft_core::addr::col_to_letters(c)
|
||||
}
|
||||
|
||||
/// The realistic mix the structural-edit benchmark uses, row-dependent.
|
||||
fn mix(i: usize, r: u32) -> String {
|
||||
let r1 = r + 1;
|
||||
match i % 12 {
|
||||
0 => format!("SUM(A{r1}:A{})", r1 + 9),
|
||||
1 => format!("VLOOKUP($B{r1},Lookup!$A$1:$D$500,3,FALSE)"),
|
||||
2 => format!("IF(AND(C{r1}>0,D{r1}<100),C{r1}*1.07,IF(E{r1}=\"x\",\"yes\",\"no\"))"),
|
||||
3 => format!("'My Sheet'!B{r1}+Data!C{r1}*2-1"),
|
||||
4 => "Table1[@Qty]*Table1[@Price]".to_string(),
|
||||
5 => format!("SUMPRODUCT(A{r1}:C{r1},{{1,2,3}})+SUM(Table1[[#Data],[Qty]])"),
|
||||
6 => format!("\"Lorem ipsum\"&A{r1}&\" units\""),
|
||||
7 => format!("IFERROR(INDEX(Lookup!$A:$A,MATCH(F{r1},Lookup!$B:$B,0)),\"\")"),
|
||||
8 => (0..20).map(|k| format!("{}{r1}", col(k))).collect::<Vec<_>>().join("+"),
|
||||
9 => format!("LET(x,A{r1}*2,y,B{r1}+x,x*y)"),
|
||||
10 => format!("ROUND(AVERAGE(B{r1}:F{r1})*(1+$H$1)^2,2)"),
|
||||
_ => format!("COUNTIFS(Data!$A:$A,A{r1},Data!$C:$C,\">=\"&$G$2)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn range(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {
|
||||
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
|
||||
}
|
||||
|
||||
/// Every edit kind, at the sheet's start, middle and edges, with counts that run past the end.
|
||||
fn edits() -> Vec<Edit> {
|
||||
let mut v = Vec::new();
|
||||
for axis in [Axis::Rows, Axis::Cols] {
|
||||
let max = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
for at in [0, 1, 2, 3, 9, max - 2, max - 1] {
|
||||
for count in [0, 1, 2, 5, max - 1, max, u32::MAX] {
|
||||
v.push(Edit::Insert { axis, at, count });
|
||||
v.push(Edit::Delete { axis, at, count });
|
||||
}
|
||||
}
|
||||
for r in [
|
||||
range(1, 1, 2, 2),
|
||||
range(0, 0, 0, 0),
|
||||
range(1, 0, 1, MAX_COLS - 1),
|
||||
range(0, 1, MAX_ROWS - 1, 1),
|
||||
range(MAX_ROWS - 2, MAX_COLS - 2, MAX_ROWS - 1, MAX_COLS - 1),
|
||||
] {
|
||||
v.push(Edit::InsertCells { axis, range: r });
|
||||
v.push(Edit::DeleteCells { axis, range: r });
|
||||
}
|
||||
}
|
||||
let blocks = [
|
||||
range(1, 1, 2, 2),
|
||||
range(0, 0, 0, 0),
|
||||
range(0, 0, MAX_ROWS - 1, MAX_COLS - 1),
|
||||
range(0, 0, MAX_ROWS - 1, 2),
|
||||
range(1, 0, 4, MAX_COLS - 1),
|
||||
range(0, 0, 20, 10),
|
||||
];
|
||||
let targets = [(9, 3), (0, 0), (1, 1), (MAX_ROWS - 1, MAX_COLS - 1), (MAX_ROWS - 2, 0)];
|
||||
for from in blocks {
|
||||
for (to_row, to_col) in targets {
|
||||
v.push(Edit::Move { from, to_row, to_col });
|
||||
for to_sheet in ["Dest", "S", "s", "Other"] {
|
||||
v.push(Edit::MoveToSheet { from, to_sheet: to_sheet.to_string(), to_row, to_col });
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Compares the new and the old adjustment of `src` for every host and edit; returns the
|
||||
/// number of (formula, host, edit) cases checked, or 0 when `src` does not parse.
|
||||
fn check(src: &str, edits: &[Edit]) -> usize {
|
||||
let Ok(e) = parse(src) else { return 0 };
|
||||
let mut n = 0;
|
||||
for host in ["S", "s", "Other", "My Sheet"] {
|
||||
for edit in edits {
|
||||
let want = old::adjust(e.clone(), host, "S", edit);
|
||||
let got = adjust(e.clone(), host, "S", edit);
|
||||
assert!(got == want, "{src} on {host} after {edit:?}: {} vs {}", print(&got), print(&want));
|
||||
assert_eq!(print(&got), print(&want), "{src} on {host} after {edit:?}");
|
||||
match adjust_if_changed(&e, host, "S", edit) {
|
||||
None => assert!(want == e, "{src} on {host} after {edit:?}: reported unchanged, old gives {}", print(&want)),
|
||||
Some(x) => {
|
||||
assert!(want != e, "{src} on {host} after {edit:?}: reported changed but the old code keeps it");
|
||||
assert!(x == want, "{src} on {host} after {edit:?}: {} vs {}", print(&x), print(&want));
|
||||
}
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (dr, dc) in [(0, 0), (1, 0), (0, 1), (-1, -1), (5, 3), (-3, 2), (MAX_ROWS as i64, 0), (0, -(MAX_COLS as i64)), (1_048_575, 16_383)] {
|
||||
let want = old::shift_relative(e.clone(), dr, dc);
|
||||
let got = shift_relative(e.clone(), dr, dc);
|
||||
assert!(got == want, "{src} shifted by ({dr}, {dc}): {} vs {}", print(&got), print(&want));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_matches_the_previous_implementation_for_every_edit_kind() {
|
||||
let edits = edits();
|
||||
let mut cases = 0;
|
||||
for src in FORMULAS {
|
||||
let n = check(src, &edits);
|
||||
assert!(n > 0, "{src} does not parse");
|
||||
cases += n;
|
||||
}
|
||||
for i in 0..48 {
|
||||
// Rows 1-2 and 1,048,565-1,048,566 of the mix (its longest range reaches 9 rows further
|
||||
// down, to the last row): near both edges of the sheet.
|
||||
let row = if i < 24 { (i / 12) as u32 } else { MAX_ROWS - 12 + (i / 12 - 2) as u32 };
|
||||
let src = mix(i, row);
|
||||
let n = check(&src, &edits);
|
||||
assert!(n > 0, "{src} does not parse");
|
||||
cases += n;
|
||||
}
|
||||
assert!(cases > 100_000, "{cases}");
|
||||
}
|
||||
|
||||
/// A small deterministic generator: a failing case is reproduced by rerunning the test, and
|
||||
/// its message names the case's index in the stream.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
fn line(&mut self, max: u32) -> u32 {
|
||||
match self.below(3) {
|
||||
0 => self.below(12) as u32,
|
||||
1 => max - 1 - self.below(12) as u32,
|
||||
_ => self.below(max as u64) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_ref(rng: &mut Rng) -> String {
|
||||
let sheet = ["", "", "S!", "s!", "Other!", "'My Sheet'!", "S:Other!"][rng.below(7) as usize];
|
||||
let dollar = |rng: &mut Rng| if rng.below(3) == 0 { "$" } else { "" };
|
||||
let cell = |rng: &mut Rng| format!("{}{}{}{}", dollar(rng), col(rng.line(MAX_COLS)), dollar(rng), rng.line(MAX_ROWS) + 1);
|
||||
let body = match rng.below(4) {
|
||||
0 => cell(rng),
|
||||
1 => format!("{}:{}", cell(rng), cell(rng)),
|
||||
2 => {
|
||||
let (a, b) = (rng.line(MAX_ROWS) + 1, rng.line(MAX_ROWS) + 1);
|
||||
format!("{}{a}:{}{b}", dollar(rng), dollar(rng))
|
||||
}
|
||||
_ => {
|
||||
let (a, b) = (rng.line(MAX_COLS), rng.line(MAX_COLS));
|
||||
format!("{}{}:{}{}", dollar(rng), col(a), dollar(rng), col(b))
|
||||
}
|
||||
};
|
||||
format!("{sheet}{body}")
|
||||
}
|
||||
|
||||
fn random_edit(rng: &mut Rng) -> Edit {
|
||||
let axis = if rng.below(2) == 0 { Axis::Rows } else { Axis::Cols };
|
||||
let max = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
let block = |rng: &mut Rng| {
|
||||
let (r0, r1) = (rng.line(MAX_ROWS), rng.line(MAX_ROWS));
|
||||
let (c0, c1) = (rng.line(MAX_COLS), rng.line(MAX_COLS));
|
||||
range(r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))
|
||||
};
|
||||
let count = if rng.below(8) == 0 { rng.below(u32::MAX as u64) as u32 } else { rng.below(20) as u32 };
|
||||
match rng.below(7) {
|
||||
0 => Edit::Insert { axis, at: rng.line(max), count },
|
||||
1 => Edit::Delete { axis, at: rng.line(max), count },
|
||||
2 => Edit::InsertCells { axis, range: block(rng) },
|
||||
3 => Edit::DeleteCells { axis, range: block(rng) },
|
||||
4 => Edit::Move { from: block(rng), to_row: rng.line(MAX_ROWS), to_col: rng.line(MAX_COLS) },
|
||||
_ => Edit::MoveToSheet {
|
||||
from: block(rng),
|
||||
to_sheet: ["Dest", "S", "other"][rng.below(3) as usize].to_string(),
|
||||
to_row: rng.line(MAX_ROWS),
|
||||
to_col: rng.line(MAX_COLS),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Random references near the sheet's edges and random edits: "unchanged" is reported exactly
|
||||
/// when the previous implementation leaves the formula as it was, and a reported change is the
|
||||
/// old result.
|
||||
#[test]
|
||||
fn adjust_if_changed_reports_unchanged_exactly_when_nothing_moves() {
|
||||
let mut rng = Rng(0x5eed);
|
||||
let (mut unchanged, mut changed) = (0, 0);
|
||||
for case in 0..20_000 {
|
||||
let refs: Vec<String> = (0..1 + rng.below(4)).map(|_| random_ref(&mut rng)).collect();
|
||||
let src = format!("SUM({})", refs.join(","));
|
||||
let Ok(e) = parse(&src) else { panic!("case {case}: {src} does not parse") };
|
||||
let edit = random_edit(&mut rng);
|
||||
let host = ["S", "Other"][rng.below(2) as usize];
|
||||
let want = old::adjust(e.clone(), host, "S", &edit);
|
||||
assert!(adjust(e.clone(), host, "S", &edit) == want, "case {case}: {src} on {host} after {edit:?}");
|
||||
match adjust_if_changed(&e, host, "S", &edit) {
|
||||
None => {
|
||||
assert!(want == e, "case {case}: {src} on {host} after {edit:?}: reported unchanged, old gives {}", print(&want));
|
||||
unchanged += 1;
|
||||
}
|
||||
Some(x) => {
|
||||
assert!(want != e && x == want, "case {case}: {src} on {host} after {edit:?}");
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both outcomes are exercised in quantity.
|
||||
assert!(unchanged > 2_000 && changed > 2_000, "unchanged {unchanged}, changed {changed}");
|
||||
}
|
||||
|
||||
/// The whole formula corpus (one formula per line, with or without `=`) when
|
||||
/// `GRIDCRAFT_ADJUST_CORPUS` names a file: `cargo test -p gridcraft-formula --test it -- --ignored adjust_corpus`.
|
||||
#[test]
|
||||
#[ignore = "needs GRIDCRAFT_ADJUST_CORPUS"]
|
||||
fn adjust_corpus_matches_the_previous_implementation() {
|
||||
let Ok(path) = std::env::var("GRIDCRAFT_ADJUST_CORPUS") else { return };
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
let edits = edits();
|
||||
let (mut parsed, mut skipped, mut cases) = (0, 0, 0);
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
match check(line, &edits) {
|
||||
0 => skipped += 1,
|
||||
n => {
|
||||
parsed += 1;
|
||||
cases += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("corpus: {parsed} formulas parsed, {skipped} skipped (do not parse), {cases} cases");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaffected_formula_is_returned_as_is() {
|
||||
let e: Expr = parse("Other!B2+SUM(A1:A3)").unwrap();
|
||||
let ins = Edit::Insert { axis: Axis::Rows, at: 5, count: 1 };
|
||||
// Row 6 inserted: A1:A3 lies above it and Other!B2 is not on S, so nothing moves, whichever
|
||||
// sheet the formula lives on (on Other, A1:A3 is on Other too).
|
||||
assert!(adjust_if_changed(&e, "S", "S", &ins).is_none());
|
||||
assert!(adjust_if_changed(&e, "Other", "S", &ins).is_none());
|
||||
// Row 1 inserted: A1:A3 on S moves, Other!B2 does not.
|
||||
let top = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 };
|
||||
assert_eq!(adjust_if_changed(&e, "S", "S", &top).map(|x| print(&x)).as_deref(), Some("Other!B2+SUM(A2:A4)"));
|
||||
// A cut that moves a block onto the same place still qualifies its references with the sheet.
|
||||
let same_place = Edit::MoveToSheet { from: range(0, 0, 2, 0), to_sheet: "S".into(), to_row: 0, to_col: 0 };
|
||||
assert_eq!(adjust_if_changed(&e, "S", "S", &same_place).map(|x| print(&x)).as_deref(), Some("Other!B2+SUM(S!A1:A3)"));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
mod adjust_oracle;
|
||||
mod printer_roundtrip;
|
||||
mod spanish_input;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user