Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 5ad62d04fa M6: misc hardening — unbounded picture cache, stats walk, signing tmpdir, web clipboard, dash budget, fs cycle, rezip loop
- floating_pictures: draw without retaining when eviction can't free
  enough room, so a large workbook can't pin the cache past its budget
- xtask stats: skip unreadable subtrees instead of aborting the walk
- import-cert.sh: per-run private temp dir instead of predictable
  paths in a shared root
- gridcraft-web: gate clipboard ops behind user activation
- chart raster: cap dash rendering to the stroked budget
- cli check: mark directories visited by identity on unix so hard-link
  loops can't recurse; reuse one decompression of a reused part
2026-10-24 06:15:02 -04:00
503 changed files with 46525 additions and 67307 deletions
-3
View File
@@ -1,3 +0,0 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
-24
View File
@@ -21,27 +21,3 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: gridcraft
app-title: GridCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+2 -4
View File
@@ -19,10 +19,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: gridcraft
app-dir: GridCraft
install-relative-path: Programs/ArtCraft/GridCraft
install-scope: perUser
+2 -2
View File
@@ -23,7 +23,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **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`).
@@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## 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`.
- Drive it: JSON lines on `127.0.0.1:7979`, 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`.
-4
View File
@@ -8,10 +8,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
+2 -2
View File
@@ -27,7 +27,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **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]`.
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## 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.
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## 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`.
- Drive it: JSON lines on `127.0.0.1:7979`, 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`.
Generated
+216 -2219
View File
File diff suppressed because it is too large Load Diff
+10 -36
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.13.0"
version = "0.11.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
rust-version = "1.90"
repository = "https://github.com/storytold/gridcraft"
[workspace.dependencies]
@@ -24,9 +24,6 @@ gridcraft-engine = { path = "crates/engine" }
gridcraft-ui-egui = { path = "crates/ui-egui" }
gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -34,8 +31,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
web-time = "1.1.0"
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.41"
@@ -47,10 +43,10 @@ sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
@@ -59,20 +55,16 @@ sys-locale = "0.3"
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
epaint_default_fonts = "0.36"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
@@ -87,21 +79,3 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+2 -262
View File
@@ -12,265 +12,5 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
Font License 1.1.
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://github.com/source-foundry/Hack
The following text is reproduced verbatim from Hack-Regular.txt:
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
The following text is reproduced verbatim from OFL.txt:
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
is distributed under the Ubuntu Font Licence, Version 1.0.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://design.ubuntu.com/font
The following text is reproduced verbatim from UFL.txt:
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
+1 -24
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,22 +135,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in |
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -160,7 +147,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | 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 | 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 |
@@ -179,7 +165,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-12 | 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) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -196,7 +181,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
@@ -242,14 +226,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it |
| 2026-10-22 | minor | Progress log: a native window per workbook window |
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
@@ -268,7 +247,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
@@ -276,7 +254,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | UI building blocks; progress log row |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
File diff suppressed because it is too large Load Diff
-237
View File
@@ -1,237 +0,0 @@
use super::*;
/// Functions whose result depends on the clock, a random generator or the environment.
const VOLATILE: [&str; 6] = ["RAND", "RANDARRAY", "RANDBETWEEN", "NOW", "TODAY", "INFO"];
// ---------------------------------------------------------------- calc oracle
fn functions_of(e: &Expr) -> Vec<String> {
let mut set = BTreeSet::new();
e.walk(&mut |x| {
if let Expr::Call(n, _) = x {
set.insert(n.clone());
}
});
set.into_iter().collect()
}
fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
let mut v = false;
e.walk(&mut |x| match x {
Expr::Call(n, args) => {
let filename = n == "CELL" && matches!(args.first(), Some(Expr::Text(t)) if t.eq_ignore_ascii_case("filename"));
if filename || VOLATILE.contains(&n.as_str()) {
v = true;
}
}
Expr::Name(n) if volatile_names.contains(&n.to_ascii_uppercase()) => v = true,
_ => {}
});
v
}
/// Upper-cased defined names whose formula is volatile, directly or through other names.
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());
}
}
if set.len() == before {
break;
}
}
set
}
/// The formula as the formula bar shows it.
pub(super) fn formula_text(cell: &Cell) -> String {
cell.bar_text()
}
fn cell_name(wb: &Workbook, (sheet, c): Key) -> String {
let name = wb.sheet(sheet).map(|s| s.name.as_str()).unwrap_or("?");
format!("{}!{}", quote_sheet(name), c.a1())
}
/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a
/// logical result `t="b"`; a writer that leaves the type out (`<c><f>TRUE()</f><v>1</v></c>`, as
/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number.
pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool {
matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 })
}
/// Whether GridCraft's `actual` agrees with the producer's `expected`.
pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool {
match (expected, actual) {
(Value::Number(a), Value::Number(b)) => (a - b).abs() <= tolerance * a.abs().max(b.abs()).max(1.0),
(Value::Text(a), Value::Text(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
_ => false,
}
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Empty => "blank",
Value::Number(_) => "number",
Value::Text(_) => "text",
Value::Bool(_) => "bool",
Value::Error(e) => e.as_str(),
Value::Array(_) => "array",
}
}
fn mismatch_kind(expected: &Value, actual: &Value) -> &'static str {
match (expected, actual) {
(_, Value::Empty) => "blank",
(Value::Number(_), Value::Number(_)) => "numeric",
(Value::Text(_), Value::Text(_)) => "text",
(Value::Bool(_), Value::Bool(_)) => "bool",
(Value::Error(_), _) | (_, Value::Error(_)) => "error",
_ => "type",
}
}
/// Mismatched cells indexed by (sheet, column) → rows, for precedent-area queries.
struct MismatchIndex {
cols: HashMap<(usize, u32), BTreeSet<u32>>,
/// Columns with mismatches per sheet (for very wide areas).
sheet_cols: HashMap<usize, BTreeSet<u32>>,
}
impl MismatchIndex {
fn new(keys: &[Key]) -> MismatchIndex {
let mut cols: HashMap<(usize, u32), BTreeSet<u32>> = HashMap::new();
let mut sheet_cols: HashMap<usize, BTreeSet<u32>> = HashMap::new();
for &(s, c) in keys {
cols.entry((s, c.col)).or_default().insert(c.row);
sheet_cols.entry(s).or_default().insert(c.col);
}
MismatchIndex { cols, sheet_cols }
}
/// Whether a mismatched cell other than `own` lies in the area.
fn any_in(&self, sheet: usize, r0: u32, r1: u32, c0: u32, c1: u32, own: Key) -> bool {
let Some(cols) = self.sheet_cols.get(&sheet) else { return false };
cols.range(c0..=c1)
.any(|&col| self.cols.get(&(sheet, col)).is_some_and(|rows| rows.range(r0..=r1).any(|&row| (sheet, CellRef::new(row, col)) != own)))
}
}
/// Compares every formula cell of `cached` (as the producer saved it) with `fresh` (recalculated
/// by GridCraft). Without `trust_cache` nothing is compared (see [`CalcReport::stale_cache`]).
pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: bool, opts: &Options) -> CalcReport {
let mut r = CalcReport { stale_cache: !trust_cache, ..Default::default() };
let vnames = volatile_names(cached);
let mut volatile: HashSet<Key> = HashSet::new();
for (si, sh) in cached.sheets.iter().enumerate() {
for (c, cell) in sh.cells.iter() {
if let Some(f) = &cell.formula {
r.formula_cells += 1;
if f.expr_arc().is_some_and(|e| is_volatile(&e, &vnames)) {
volatile.insert((si, c));
}
}
}
}
// Everything downstream of a volatile formula is volatile too; each key is pushed once.
let mut stack: Vec<Key> = volatile.iter().copied().collect();
let mut deps = Vec::new();
while let Some((s, c)) = stack.pop() {
deps.clear();
calc.graph.dependents(s, c, &mut deps);
if let Some(range) = fresh.sheet(s).and_then(|sh| sh.spill_ranges.get(&c)) {
calc.graph.dependents_of_range(s, *range, &mut deps);
}
for k in deps.drain(..) {
if volatile.insert(k) {
stack.push(k);
}
}
}
let mut bad: Vec<(Key, Vec<String>)> = Vec::new();
for (si, sh) in cached.sheets.iter().enumerate() {
for (c, cell) in sh.cells.iter() {
let Some(f) = &cell.formula else { continue };
if volatile.contains(&(si, c)) {
r.volatile += 1;
continue;
}
if cell.value.is_empty() || !trust_cache {
r.no_oracle += 1;
continue;
}
r.checked += 1;
let actual = fresh.sheet(si).and_then(|s| s.cell(c)).map(|x| x.value.scalar()).unwrap_or_default();
let funcs = match f.expr_arc() {
Some(e) => {
let names = functions_of(&e);
if names.is_empty() { vec!["(no function)".to_string()] } else { names }
}
None => vec!["(unparsed)".to_string()],
};
let bool_as_number = bool_cached_as_number(&cell.value, &actual);
let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance);
if bool_as_number {
r.bool_as_number += 1;
}
for n in &funcs {
let st = r.functions.entry(n.clone()).or_default();
st.checked += 1;
if !ok {
st.mismatched += 1;
}
}
if ok {
r.matched += 1;
} else {
r.mismatched += 1;
*r.signatures.entry(format!("{} → {}", type_name(&cell.value), type_name(&actual))).or_default() += 1;
bad.push(((si, c), funcs));
}
}
}
// A mismatch is primary when none of its precedent cells mismatch.
let keys: Vec<Key> = bad.iter().map(|(k, _)| *k).collect();
let index = MismatchIndex::new(&keys);
let mut listed: Vec<(bool, Key, Vec<String>)> = Vec::with_capacity(bad.len());
for (k, funcs) in bad {
let upstream = calc
.graph
.precedents_of(k)
.iter()
.any(|a| index.any_in(a.sheet, a.range.start.row, a.range.end.row, a.range.start.col, a.range.end.col, k));
if !upstream {
for n in &funcs {
r.functions.entry(n.clone()).or_default().primary += 1;
}
}
listed.push((!upstream, k, funcs));
}
// Primary mismatches first, each group in sheet then row-major order (already sorted).
listed.sort_by_key(|(primary, _, _)| !*primary);
for (primary, k, functions) in listed.into_iter().take(opts.max_report) {
let cell = cached.sheet(k.0).and_then(|s| s.cell(k.1));
let expected = cell.map(|c| c.value.clone()).unwrap_or_default();
let actual = fresh.sheet(k.0).and_then(|s| s.cell(k.1)).map(|x| x.value.scalar()).unwrap_or_default();
r.mismatches.push(Mismatch {
cell: cell_name(cached, k),
formula: cell.map(formula_text).unwrap_or_default(),
kind: mismatch_kind(&expected, &actual),
expected,
actual,
functions,
primary,
});
}
r
}
-185
View File
@@ -1,185 +0,0 @@
use super::calc::formula_text;
use super::package::{REBUILT_KINDS, relationship_kinds, validate_package};
use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet};
// ---------------------------------------------------------------- round trip
/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against
/// `source` (the file `wb` was read from), reads it back and compares.
pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip {
let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") {
Ok(b) => b,
Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] },
};
let mut package = Diffs::new("package", opts.max_report);
match validate_package(&bytes) {
Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)),
Err(e) => package.push(|| e),
}
if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) {
for (key, kind) in &before {
if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) {
package.push(|| format!("lost relationship {kind}"));
}
}
}
let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) {
Ok((b, _)) => b,
Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] },
};
let mut categories = diff_workbooks(wb, &back, opts);
categories.push(package.done());
RoundTrip { error: None, categories }
}
pub(super) fn value_show(v: &Value) -> String {
match v {
Value::Empty => "(blank)".to_string(),
Value::Text(t) => serde_json::to_string(t.as_ref()).unwrap_or_default(),
Value::Number(n) => number_text(*n),
Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
Value::Error(e) => e.as_str().to_string(),
Value::Array(_) => v.scalar().display(),
}
}
fn opt_formula(c: Option<&Cell>) -> String {
c.filter(|c| c.formula.is_some()).map(formula_text).unwrap_or_default()
}
/// Per-sheet comparison of two lists, reporting what one side has and the other lacks.
fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet<String>, b: &BTreeSet<String>) {
for x in a.difference(b) {
d.push(|| format!("{sheet}: lost {x}"));
}
for x in b.difference(a) {
d.push(|| format!("{sheet}: gained {x}"));
}
}
/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number
/// itself is left out, so renumbering without changing the order isn't a difference.
fn cond_format_list(s: &Sheet) -> Vec<String> {
let mut cfs: Vec<_> = s.cond_formats.iter().collect();
cfs.sort_by_key(|c| c.priority);
cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect()
}
fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) {
if a != b {
d.push(|| format!("{sheet}: {a} → {b}"));
}
}
/// A value as the file format can hold it: `#SPILL!`/`#CALC!` are cached as `#VALUE!` and an
/// unresolved circular reference as 0 (what Excel writes), so those aren't round-trip losses.
fn as_cached(v: Value) -> Value {
match v {
Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value),
Value::Error(CellError::Circ) => Value::Number(0.0),
v => v,
}
}
fn num_fmt<'a>(wb: &'a Workbook, c: Option<&Cell>) -> &'a str {
wb.styles.get(c.map(|c| c.style).unwrap_or_default()).num_fmt.as_str()
}
/// Feature-by-feature comparison of a workbook (`a`) with what reading it back gave (`b`). Sheets
/// are paired by position.
pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec<Category> {
let m = opts.max_report;
let mut sheets = Diffs::new("sheets", m);
let (an, bn): (Vec<&str>, Vec<&str>) = (a.sheets.iter().map(|s| s.name.as_str()).collect(), b.sheets.iter().map(|s| s.name.as_str()).collect());
if an != bn {
sheets.push(|| format!("{an:?} → {bn:?}"));
}
let mut values = Diffs::new("values", m);
let mut formulas = Diffs::new("formulas", m);
let mut num_fmts = Diffs::new("numFmts", m);
let mut merges = Diffs::new("merges", m);
let mut tables = Diffs::new("tables", m);
let mut cond_formats = Diffs::new("condFormats", m);
let mut validations = Diffs::new("validations", m);
let mut charts = Diffs::new("charts", m);
let mut comments = Diffs::new("comments", m);
let mut hyperlinks = Diffs::new("hyperlinks", m);
let mut pivots = Diffs::new("pivots", m);
for (sa, sb) in a.sheets.iter().zip(b.sheets.iter()) {
let name = quote_sheet(&sa.name);
let mut cell = |c: CellRef, ca: Option<&Cell>, cb: Option<&Cell>| {
let cached = |x: Option<&Cell>| as_cached(x.map(|x| x.value.scalar()).unwrap_or_default());
let (va, vb) = (cached(ca), cached(cb));
if va != vb {
values.push(|| format!("{name}!{}: {} → {}", c.a1(), value_show(&va), value_show(&vb)));
}
let (fa, fb) = (opt_formula(ca), opt_formula(cb));
if fa != fb {
formulas.push(|| format!("{name}!{}: {fa} → {fb}", c.a1()));
}
let (na, nb) = (num_fmt(a, ca), num_fmt(b, cb));
if na != nb {
num_fmts.push(|| format!("{name}!{}: {na} → {nb}", c.a1()));
}
};
for (c, ca) in sa.cells.iter() {
cell(c, Some(ca), sb.cell(c));
}
for (c, cb) in sb.cells.iter() {
if sa.cell(c).is_none() {
cell(c, None, Some(cb));
}
}
let set = |f: &dyn Fn(&Sheet) -> Vec<String>, s: &Sheet| f(s).into_iter().collect::<BTreeSet<String>>();
let merge_list = |s: &Sheet| -> Vec<String> { s.merges.iter().map(|r| r.a1()).collect() };
diff_sets(&mut merges, &name, &set(&merge_list, sa), &set(&merge_list, sb));
let table_list = |s: &Sheet| -> Vec<String> {
s.tables
.iter()
.map(|t| format!("{} {} [{}]", t.name, t.range.a1(), t.columns.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(", ")))
.collect()
};
diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb));
let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb));
if cfa.len() != cfb.len() {
cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len()));
} else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) {
cond_formats.push(|| format!("{name}: {x} → {y}"));
}
diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len());
let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::<Vec<_>>();
let (ca, cb) = (chart_list(sa), chart_list(sb));
if ca != cb {
charts.push(|| format!("{name}: {ca:?} → {cb:?}"));
}
diff_count(&mut comments, &name, sa.comments.len(), sb.comments.len());
diff_count(&mut hyperlinks, &name, sa.hyperlinks.len(), sb.hyperlinks.len());
diff_count(&mut pivots, &name, sa.pivots.len(), sb.pivots.len());
}
let mut names = Diffs::new("names", m);
let name_list = |wb: &Workbook| -> BTreeSet<String> {
wb.names
.iter()
.map(|n| {
let scope = n.scope.map(|i| format!("{}!", quote_sheet(wb.sheet(i).map(|s| s.name.as_str()).unwrap_or("?")))).unwrap_or_default();
format!("{scope}{}={}", n.name, n.formula)
})
.collect()
};
diff_sets(&mut names, "workbook", &name_list(a), &name_list(b));
vec![
sheets.done(),
values.done(),
formulas.done(),
num_fmts.done(),
merges.done(),
names.done(),
tables.done(),
cond_formats.done(),
validations.done(),
charts.done(),
comments.done(),
hyperlinks.done(),
pivots.done(),
]
}
-331
View File
@@ -1,331 +0,0 @@
use super::*;
/// Largest package part read while validating a written package.
const MAX_XML_PART: u64 = 64 * 1024 * 1024;
/// Most zip entries looked at while validating a written package.
const MAX_ENTRIES: usize = 200_000;
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
/// a cache Excel rebuilds, and GridCraft never writes it.
pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];
// ---------------------------------------------------------------- package validation
fn local_name(name: &[u8]) -> &[u8] {
match name.iter().rposition(|&b| b == b':') {
Some(i) => name.get(i + 1..).unwrap_or(name),
None => name,
}
}
/// `(element local name, attributes)` for every start/empty element of an XML part.
type XmlElement = (String, Vec<(String, String)>);
fn xml_elements(xml: &[u8]) -> Result<Vec<XmlElement>, String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_reader(xml);
let mut buf = Vec::new();
let mut out = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let attrs = e
.attributes()
.with_checks(false)
.flatten()
.map(|a| {
let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) {
Ok(v) => v.into_owned(),
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
};
(String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v)
})
.collect();
out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs));
}
Ok(Event::Eof) => break,
Ok(_) => {}
Err(e) => return Err(format!("invalid XML: {e}")),
}
buf.clear();
}
Ok(out)
}
fn attr<'a>(attrs: &'a [(String, String)], k: &str) -> Option<&'a str> {
attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
}
/// Lower-cased part name without a leading slash: OPC part names compare case-insensitively.
fn part_key(name: &str) -> String {
name.trim_start_matches('/').to_ascii_lowercase()
}
/// `%XX` escapes in a relationship target.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = |j: usize| b.get(j).and_then(|h| (*h as char).to_digit(16));
match (c, hex(i + 1), hex(i + 2)) {
(b'%', Some(h), Some(l)) => {
out.push((h * 16 + l) as u8);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// Resolves a relationship target against the source part's directory.
fn resolve_target(source_dir: &str, target: &str) -> String {
let target = percent_decode(target.split('#').next().unwrap_or(target));
let joined = match target.strip_prefix('/') {
Some(abs) => abs.to_string(),
None if source_dir.is_empty() => target,
None => format!("{source_dir}/{target}"),
};
let mut parts: Vec<&str> = Vec::new();
for seg in joined.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
type Zip<'a> = zip::ZipArchive<Cursor<&'a [u8]>>;
fn zip_names(zip: &mut Zip<'_>) -> Vec<String> {
(0..zip.len().min(MAX_ENTRIES)).filter_map(|i| zip.by_index_raw(i).ok().filter(|f| !f.is_dir()).map(|f| f.name().to_string())).collect()
}
fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result<Vec<u8>, String> {
let f = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?;
let mut buf = Vec::new();
f.take(MAX_XML_PART).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?;
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 {
let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false };
let names = zip_names(&mut zip);
let find = |key: &str| names.iter().find(|n| part_key(n) == key).cloned();
let mut workbook = "xl/workbook.xml".to_string();
if let Some(rels) = find("_rels/.rels")
&& let Ok(xml) = read_zip_part(&mut zip, &rels)
&& let Ok(els) = xml_elements(&xml)
&& let Some(target) = els
.iter()
.find(|(e, a)| e == "Relationship" && attr(a, "Type").is_some_and(|t| t.ends_with("/officeDocument")))
.and_then(|(_, a)| attr(a, "Target"))
{
workbook = resolve_target("", target);
}
let Some(name) = find(&part_key(&workbook)) else { return false };
let Ok(xml) = read_zip_part(&mut zip, &name) else { return false };
xml_elements(&xml)
.ok()
.into_iter()
.flatten()
.any(|(e, a)| e == "calcPr" && attr(&a, "fullCalcOnLoad").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")))
}
/// Problems Excel would repair in a written package: parts without a content type, content-type
/// overrides for missing parts, relationship files for missing parts, duplicate relationship ids
/// and internal relationships pointing at missing parts. `Err` when the package can't be read.
pub fn validate_package(bytes: &[u8]) -> Result<Vec<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 parts: HashSet<String> = names.iter().map(|n| part_key(n)).collect();
let mut read_part = |name: &str| read_zip_part(&mut zip, name);
let mut issues = Vec::new();
let ct_name = names.iter().find(|n| part_key(n) == "[content_types].xml").cloned();
let (mut defaults, mut overrides) = (HashSet::new(), HashSet::new());
match ct_name {
None => issues.push("no [Content_Types].xml".to_string()),
Some(n) => {
for (el, attrs) in xml_elements(&read_part(&n)?).map_err(|e| format!("[Content_Types].xml: {e}"))? {
match el.as_str() {
"Default" => {
if let Some(x) = attr(&attrs, "Extension") {
defaults.insert(x.to_ascii_lowercase());
}
}
"Override" => {
if let Some(p) = attr(&attrs, "PartName") {
overrides.insert(part_key(p));
}
}
_ => {}
}
}
}
}
let mut sorted: Vec<&String> = names.iter().collect();
sorted.sort();
for n in &sorted {
let key = part_key(n);
if key == "[content_types].xml" {
continue;
}
let ext = key.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_string());
if !overrides.contains(&key) && !ext.is_some_and(|e| defaults.contains(&e)) {
issues.push(format!("part {n} has no content type"));
}
}
let mut missing_overrides: Vec<&String> = overrides.iter().filter(|p| !parts.contains(*p)).collect();
missing_overrides.sort();
for p in missing_overrides {
issues.push(format!("content type override for missing part /{p}"));
}
for n in sorted {
let key = part_key(n);
let Some(rels_file) = key.strip_suffix(".rels") else { continue };
// `dir/_rels/name.rels` describes `dir/name`; `_rels/.rels` the package itself.
let (dir, file) = match rels_file.rsplit_once('/') {
Some((d, f)) => (d, f),
None => continue,
};
let Some(src_dir) = dir.strip_suffix("_rels").map(|d| d.trim_end_matches('/')) else { continue };
let source = if src_dir.is_empty() { file.to_string() } else { format!("{src_dir}/{file}") };
if !source.is_empty() && !parts.contains(&source) {
issues.push(format!("{n} describes missing part {source}"));
}
let mut ids = HashSet::new();
for (el, attrs) in xml_elements(&read_part(n)?).map_err(|e| format!("{n}: {e}"))? {
if el != "Relationship" {
continue;
}
let id = attr(&attrs, "Id").unwrap_or("");
if !ids.insert(id.to_string()) {
issues.push(format!("{n}: duplicate relationship id {id}"));
}
if attr(&attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) {
continue;
}
let target = attr(&attrs, "Target").unwrap_or("");
let resolved = resolve_target(src_dir, target);
if !parts.contains(&resolved.to_ascii_lowercase()) {
let kind = attr(&attrs, "Type").and_then(|t| t.rsplit('/').next()).unwrap_or("?");
issues.push(format!("{n}: {id} ({kind}) targets missing part {resolved}"));
}
}
}
Ok(issues)
}
/// Every relationship kind (last segment of the type, the same in the transitional and strict
/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
pub(super) fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
let names = zip_names(&mut zip);
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
// 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).
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 };
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
let target = attr(attrs, "Target").unwrap_or("");
if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') {
continue;
}
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
if !external
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
{
continue;
}
kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string());
}
}
Ok(kinds)
}
/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the
/// empty string for the package's `_rels/.rels`.
fn rels_source_dir(key: &str) -> Option<String> {
let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?;
Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string())
}
/// Whether a part has content: an element under its root, or for comments (whose authors list
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
/// package's remaining checks may cause, and once it is spent the part counts as having content.
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
"comments" => Some(b"comment"),
"vmldrawing" => Some(b"shape"),
_ => None,
};
let marker = match wanted {
Some(b"comment") => "comment",
Some(_) => "shape",
None => "any",
};
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
return *v;
}
let v = holds_something_uncached(zip, name, marker, budget);
verdicts.insert((name.to_ascii_lowercase(), marker), v);
v
}
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
use quick_xml::events::Event;
let Ok(f) = zip.by_name(name) else { return true };
if *budget == 0 {
return true;
}
let take = f.size().min(MAX_XML_PART).min(*budget);
*budget -= take;
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
let wanted: Option<&'static [u8]> = match marker {
"comment" => Some(b"comment"),
"shape" => Some(b"shape"),
_ => None,
};
let mut buf = Vec::new();
let mut in_root = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let found = match wanted {
Some(w) => local_name(e.name().as_ref()) == w,
None => in_root,
};
if found {
return true;
}
in_root = true;
}
Ok(Event::Eof) => return false,
Ok(_) => {}
Err(_) => return true,
}
buf.clear();
}
}
-280
View File
@@ -1,280 +0,0 @@
use super::fidelity::value_show;
use super::*;
// ---------------------------------------------------------------- output
#[derive(Default)]
struct Totals {
files: usize,
opened: usize,
failed: usize,
formula_cells: usize,
checked: usize,
matched: usize,
mismatched: usize,
no_oracle: usize,
volatile: usize,
bool_as_number: usize,
round_trip_failed: usize,
round_trip_differing: usize,
categories: BTreeMap<&'static str, usize>,
functions: BTreeMap<String, FnStat>,
signatures: BTreeMap<String, usize>,
}
fn totals(reports: &[FileReport]) -> Totals {
let mut t = Totals { files: reports.len(), ..Default::default() };
for r in reports {
if r.error.is_some() {
t.failed += 1;
continue;
}
t.opened += 1;
t.formula_cells += r.calc.formula_cells;
t.checked += r.calc.checked;
t.matched += r.calc.matched;
t.mismatched += r.calc.mismatched;
t.no_oracle += r.calc.no_oracle;
t.volatile += r.calc.volatile;
t.bool_as_number += r.calc.bool_as_number;
if r.round_trip.error.is_some() {
t.round_trip_failed += 1;
}
if !r.round_trip.is_clean() {
t.round_trip_differing += 1;
}
for c in r.round_trip.differing() {
*t.categories.entry(c).or_default() += 1;
}
for (n, s) in &r.calc.functions {
t.functions.entry(n.clone()).or_default().add(s);
}
for (sig, n) in &r.calc.signatures {
*t.signatures.entry(sig.clone()).or_default() += n;
}
}
t
}
/// Functions with mismatches, worst first: most primary mismatches, then most mismatches.
fn ranked(functions: &BTreeMap<String, FnStat>) -> Vec<(&String, &FnStat)> {
let mut v: Vec<(&String, &FnStat)> = functions.iter().filter(|(_, s)| s.mismatched > 0).collect();
v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0)));
v
}
fn functions_json(functions: &BTreeMap<String, FnStat>) -> Json {
let mut v: Vec<(&String, &FnStat)> = functions.iter().collect();
v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0)));
Json::Array(v.into_iter().map(|(n, s)| json!({"name": n, "checked": s.checked, "mismatched": s.mismatched, "primary": s.primary})).collect())
}
pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
let files: Vec<Json> = reports
.iter()
.map(|r| {
let c = &r.calc;
let mismatches: Vec<Json> = c
.mismatches
.iter()
.map(|m| {
json!({
"cell": m.cell, "formula": m.formula, "expected": value_json(&m.expected), "actual": value_json(&m.actual),
"kind": m.kind, "functions": m.functions, "primary": m.primary,
})
})
.collect();
let categories: serde_json::Map<String, Json> = r
.round_trip
.categories
.iter()
.map(|cat| (cat.name.to_string(), json!({"equal": cat.diffs == 0, "diffs": cat.diffs, "examples": cat.examples})))
.collect();
json!({
"path": r.path,
"opened": r.error.is_none(),
"error": r.error,
"warnings": r.warnings,
"calc": {
"formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched,
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number,
"mismatches": mismatches, "functions": functions_json(&c.functions),
"signatures": c.signatures,
},
"roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories},
})
})
.collect();
let t = totals(reports);
json!({
"schema": SCHEMA,
"tolerance": opts.tolerance,
"files": files,
"totals": {
"files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked,
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number,
"roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories,
},
"functions": functions_json(&t.functions),
"signatures": t.signatures,
})
}
/// One report line: line breaks and tabs shown escaped so that they stay visible as differences.
fn one_line(s: &str) -> String {
s.replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t")
}
/// `…tail` of a long label.
fn fit(s: &str, w: usize) -> String {
let n = s.chars().count();
if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::<String>()) }
}
fn print_file_reports(reports: &[FileReport]) {
for r in reports {
outln!("== {}", r.path);
if let Some(e) = &r.error {
outln!(" can't open: {e}");
continue;
}
let c = &r.calc;
outln!(
" formulas {}: {} checked, {} matched, {} mismatched, {} no oracle, {} volatile",
c.formula_cells,
c.checked,
c.matched,
c.mismatched,
c.no_oracle,
c.volatile
);
if c.stale_cache {
outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)");
}
if c.bool_as_number > 0 {
outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number);
}
for m in &c.mismatches {
let tag = if m.primary { "mismatch" } else { " (downstream)" };
outln!(
" {tag} {} {} expected {} got {} [{}]",
m.cell,
one_line(&m.formula),
value_show(&m.expected),
value_show(&m.actual),
m.functions.join(" ")
);
}
if c.mismatched > c.mismatches.len() {
outln!(" … {} more mismatches", c.mismatched - c.mismatches.len());
}
let rt = &r.round_trip;
if let Some(e) = &rt.error {
outln!(" round trip: {e}");
}
let differing: Vec<&Category> = rt.categories.iter().filter(|c| c.diffs > 0).collect();
if differing.is_empty() && rt.error.is_none() {
outln!(" round trip: all equal");
}
for cat in differing {
outln!(" round trip {}: {} difference(s)", cat.name, cat.diffs);
for e in &cat.examples {
outln!(" {}", one_line(e));
}
if cat.diffs > cat.examples.len() {
outln!(" … {} more", cat.diffs - cat.examples.len());
}
}
}
}
fn print_file_table(reports: &[FileReport]) {
let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50);
outln!(
"{:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip",
"file",
"formulas",
"checked",
"matched",
"mismatch",
"no-oracle",
"volatile"
);
for r in reports {
let label = fit(&r.path, w);
if let Some(e) = &r.error {
outln!("{label:<w$} can't open: {}", one_line(&fit(e, 100)));
continue;
}
let c = &r.calc;
let rt = match (&r.round_trip.error, r.round_trip.differing()) {
(Some(_), _) => "FAILED".to_string(),
(None, d) if d.is_empty() => "ok".to_string(),
(None, d) => d.join(","),
};
outln!(
"{label:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} {rt}",
c.formula_cells,
c.checked,
c.matched,
c.mismatched,
c.no_oracle,
c.volatile
);
}
}
fn print_rankings(t: &Totals, opts: &Options) {
let top = ranked(&t.functions);
if !top.is_empty() {
outln!("");
outln!("top mismatching functions (primary = no mismatching precedent):");
outln!(" {:<20} {:>8} {:>10} {:>8}", "function", "primary", "mismatched", "checked");
for (n, s) in top.iter().take(opts.max_report.max(1)) {
outln!(" {:<20} {:>8} {:>10} {:>8}", n, s.primary, s.mismatched, s.checked);
}
}
let mut sigs: Vec<(&String, &usize)> = t.signatures.iter().collect();
sigs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
if !sigs.is_empty() {
outln!("");
outln!("mismatch signatures (expected → GridCraft):");
for (sig, n) in sigs.iter().take(opts.max_report.max(1)) {
outln!(" {n:>8} {sig}");
}
}
}
fn print_totals(t: &Totals) {
outln!("");
outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed);
outln!(
"formula cells: {} ({} checked: {} matched, {} mismatched; {} no oracle; {} volatile)",
t.formula_cells,
t.checked,
t.matched,
t.mismatched,
t.no_oracle,
t.volatile
);
if t.bool_as_number > 0 {
outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number);
}
let cats: Vec<String> = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect();
outln!(
"round trip: {} of {} files differ{}{}",
t.round_trip_differing,
t.opened,
if t.round_trip_failed > 0 { format!(" ({} failed)", t.round_trip_failed) } else { String::new() },
if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) }
);
}
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
print_file_reports(reports);
outln!("");
print_file_table(reports);
let t = totals(reports);
print_rankings(&t, opts);
print_totals(&t);
}
-450
View File
@@ -1,450 +0,0 @@
#![allow(clippy::unwrap_used)]
use std::io::Write as _;
use std::sync::Arc;
use gridcraft_engine::model::Formula;
use super::*;
#[test]
fn discovery_reports_a_directory_read_error() {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
let mut seen = std::collections::HashSet::new();
assert!(walk(&path, 0, &mut discovery, &mut seen));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
let _ = std::fs::remove_file(path);
}
#[test]
fn discovery_reports_omissions_only_after_the_file_limit() {
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
assert!(discovery.failures.is_empty());
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
assert_eq!(discovery.files.len(), MAX_FILES);
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
}
fn at(a1: &str) -> CellRef {
CellRef::parse(a1).unwrap()
}
/// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a
/// formula with that cached value, anything else a constant.
fn book(cells: &[(&str, &str, Value)]) -> Workbook {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
for (a1, input, cached) in cells {
let cell = if input.starts_with('=') {
Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() }
} else {
Cell::value(cached.clone())
};
sh.set_cell(at(a1), cell);
}
wb
}
fn check(wb: &Workbook, opts: &Options) -> FileReport {
let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap();
check_bytes("t.xlsx", &bytes, opts)
}
fn n(x: f64) -> Value {
Value::number(x)
}
#[test]
fn matching_cached_values_pass() {
let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]);
let r = check(&wb, &Options::default());
assert_eq!(r.error, None);
assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0));
assert!(r.is_clean(), "{r:?}");
}
#[test]
fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() {
// Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with
// Excel's 7, so it mismatches too, but only as a downstream effect.
let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2));
let first = &r.calc.mismatches[0];
assert_eq!(first.cell, "Sheet1!B1");
assert_eq!(first.formula, "=SUM(A1:A2)");
assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true));
assert!(!r.calc.mismatches[1].primary);
assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 });
assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 });
assert!(!r.is_clean());
}
#[test]
fn errors_compare_by_kind() {
let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2));
assert!(r.calc.mismatches.iter().all(|m| m.kind == "error"));
assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1));
assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1));
assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0));
assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0));
}
#[test]
fn numbers_compare_within_tolerance() {
assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9));
assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12));
// Absolute near zero.
assert!(values_match(&n(1e-12), &n(0.0), 1e-9));
assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9));
assert!(!values_match(&n(0.0), &Value::Empty, 1.0));
assert!(!values_match(&Value::text("1"), &n(1.0), 1.0));
assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0));
assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0));
assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0));
let wb = book(&[("A1", "=1+2", n(3.0000000001))]);
assert_eq!(check(&wb, &Options::default()).calc.matched, 1);
assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
}
/// Business Central writes TRUE and FALSE as `<c><f>TRUE()</f><v>1</v></c>`, without `t="b"`:
/// the cache holds the right value under a number type, so it matches and is counted apart.
#[test]
fn booleans_cached_as_numbers_match_and_are_counted_apart() {
let wb = book(&[
("A1", "=TRUE()", n(1.0)),
("B1", "=FALSE()", n(0.0)),
("C1", "=TRUE()", Value::Bool(true)),
("D1", "=1=1", n(0.0)),
("E1", "=TRUE()+0", n(1.0)),
]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1));
// A cached 0 for TRUE is still a mismatch, and a number result is compared as a number.
assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1");
assert_eq!(r.calc.signatures.get("number → bool"), Some(&1));
assert_eq!(r.calc.bool_as_number, 2);
let v = report_json(std::slice::from_ref(&r), &Options::default());
assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2)));
assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true)));
assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false)));
assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true)));
assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0)));
}
#[test]
fn missing_cached_values_and_volatile_formulas_are_skipped() {
let wb =
book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3));
}
#[test]
fn full_calc_on_load_means_no_oracle() {
// A formula without a cached value makes GridCraft's writer flag the file
// `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached"
// value must not count as a mismatch then.
let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]);
let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
assert!(full_calc_on_load(&bytes));
let r = check_bytes("t.xlsx", &bytes, &Options::default());
assert!(r.calc.stale_cache);
assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0));
let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap();
assert!(!full_calc_on_load(&fresh));
assert!(!full_calc_on_load(b"not a zip"));
}
#[test]
fn volatile_names_propagate() {
let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]);
wb.names.push(gridcraft_engine::model::DefinedName {
name: "Stamp".into(),
scope: None,
formula: "TODAY()".into(),
comment: String::new(),
hidden: false,
});
assert!(volatile_names(&wb).contains("STAMP"));
assert_eq!(check(&wb, &Options::default()).calc.volatile, 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"))]);
wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
let rt = round_trip(&wb, &source, &Options::default());
assert_eq!(rt.error, None);
assert!(rt.is_clean(), "{rt:?}");
assert!(rt.categories.iter().any(|c| c.name == "package"));
}
#[test]
fn diff_detects_lost_features() {
let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]);
a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
let mut b = a.clone();
let sh = b.sheet_mut(0).unwrap();
sh.merges.clear();
sh.set_cell(at("A1"), Cell::value(n(3.0)));
sh.set_cell(at("B1"), Cell::value(n(6.0)));
b.sheets.push(Arc::new(Sheet::new("Extra")));
let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() });
let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap();
assert_eq!(get("values").diffs, 1);
assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]);
assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]);
assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]);
assert_eq!(get("sheets").diffs, 1);
assert_eq!(get("tables").diffs, 0);
}
#[test]
fn diff_detects_changed_cond_format_rule() {
use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
let bar = |gradient: bool, priority: u32| CondFormat {
ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
priority,
stop_if_true: false,
};
let with = |cf: CondFormat| {
let mut wb = book(&[("A1", "2", n(2.0))]);
wb.sheet_mut(0).unwrap().cond_formats.push(cf);
wb
};
let diffs = |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
}
fn zip_of(parts: &[(&str, &str)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in parts {
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(body.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
#[test]
fn package_validation_finds_what_excel_would_repair() {
let bytes = zip_of(&[
(
"[Content_Types].xml",
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="r"/><Override PartName="/xl/workbook.xml" ContentType="w"/><Override PartName="/xl/gone.xml" ContentType="g"/></Types>"#,
),
("_rels/.rels", r#"<Relationships><Relationship Id="rId1" Type="x/officeDocument" Target="xl/workbook.xml"/></Relationships>"#),
("xl/workbook.xml", "<workbook/>"),
("xl/styles.xml", "<styleSheet/>"),
(
"xl/_rels/workbook.xml.rels",
r#"<Relationships><Relationship Id="rId1" Type="x/styles" Target="styles.xml"/><Relationship Id="rId2" Type="x/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="x/hyperlink" Target="https://example.com" TargetMode="External"/><Relationship Id="rId3" Type="x/theme" Target="/xl/../xl/styles.xml"/></Relationships>"#,
),
]);
let issues = validate_package(&bytes).unwrap();
assert_eq!(
issues,
[
"part xl/styles.xml has no content type",
"content type override for missing part /xl/gone.xml",
"xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml",
"xl/_rels/workbook.xml.rels: duplicate relationship id rId2",
]
);
assert!(validate_package(b"not a zip").is_err());
let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap();
assert_eq!(validate_package(&own).unwrap(), Vec::<String>::new());
}
const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
const MADE_UP: &str = r#"<Relationship Id="rId90" Type="http://example.com/relationships/madeUp" Target="madeUp.xml"/>"#;
/// Not empty: an empty part isn't a loss.
const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", "<madeUp><item/></madeUp>");
/// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut pending: Vec<(&str, &str)> = rels.to_vec();
for i in 0..src.len() {
let mut f = src.by_index(i).unwrap();
let name = f.name().to_string();
let mut body = String::new();
f.read_to_string(&mut body).unwrap();
if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
body = body.replace("</Relationships>", &format!("{}</Relationships>", pending.remove(at).1));
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(body.as_bytes()).unwrap();
}
let created = pending.iter().map(|(r, extra)| {
(*r, format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{extra}</Relationships>"#))
});
for (name, body) in created.collect::<Vec<_>>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(body.as_bytes()).unwrap();
}
out.finish().unwrap().into_inner()
}
/// The `package` examples of checking `source`.
fn package_of(source: &[u8]) -> Vec<String> {
let r = check_bytes("t.xlsx", source, &Options::default());
r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
}
fn plain() -> Vec<u8> {
gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
}
#[test]
fn package_reports_relationship_kinds_the_output_lost() {
// A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
// (Excel rebuilds it), so its absence isn't a loss.
let calc_chain =
r#"<Relationship Id="rId91" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" Target="calcChain.xml"/>"#;
let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "<calcChain/>")]);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
}
// In the three tests below the made-up kind stays lost, so the comparison is known to run.
#[test]
fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
// openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
let link = r##"<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="#Sheet1!B2" TargetMode="External"/>"##;
let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn comments_without_a_comment_are_not_a_loss() {
let rels = r#"<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>"#;
let comments = r#"<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList/></comments>"#;
let vml = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><o:shapelayout><o:idmap data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" path="m,l,21600r21600,l21600,xe"><v:path gradientshapeok="t"/></v:shapetype></xml>"#;
let source = with_rels(
&plain(),
&[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
&[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn empty_drawings_and_person_lists_are_not_a_loss() {
let drawing = r#"<Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>"#;
let person = r#"<Relationship Id="rId92" Type="http://schemas.microsoft.com/office/2017/10/relationships/person" Target="persons/person.xml"/>"#;
let source = with_rels(
&plain(),
&[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
&[
MADE_UP_PART,
("xl/drawings/drawing1.xml", r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"/>"#),
("xl/persons/person.xml", r#"<personList xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"/>"#),
],
);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn json_lists_warning_messages() {
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
let v = report_json(&[with, without], &Options::default());
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
assert_eq!(v["files"][1]["warnings"], json!([]));
}
#[test]
fn unopenable_files_are_reported() {
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
assert!(r.error.is_some());
assert!(!r.is_clean());
}
#[test]
fn json_schema_keys() {
let wb = book(&[("A1", "=1+1", n(3.0))]);
let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())];
let v = report_json(&reports, &Options::default());
let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::<BTreeSet<String>>();
let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::<BTreeSet<String>>();
assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"]));
assert_eq!(v["schema"], SCHEMA);
let f = &v["files"][0];
assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
assert_eq!(
keys(&f["calc"]),
set(&[
"formulaCells",
"checked",
"matched",
"mismatched",
"noOracle",
"staleCache",
"volatile",
"boolAsNumber",
"mismatches",
"functions",
"signatures"
])
);
assert_eq!(f["calc"]["signatures"], json!({"number → number": 1}));
assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"]));
assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0));
assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"]));
assert_eq!(
keys(&f["roundTrip"]["categories"]),
set(&[
"sheets",
"values",
"formulas",
"numFmts",
"merges",
"names",
"tables",
"condFormats",
"validations",
"charts",
"comments",
"hyperlinks",
"pivots",
"package"
])
);
assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"]));
assert_eq!(v["files"][1]["opened"], json!(false));
assert_eq!(
keys(&v["totals"]),
set(&[
"files",
"opened",
"failedToOpen",
"formulaCells",
"checked",
"matched",
"mismatched",
"noOracle",
"volatile",
"boolAsNumber",
"roundTripFailed",
"roundTripDiffering",
"categoriesDiffering"
])
);
assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"]));
}
+1 -17
View File
@@ -195,18 +195,7 @@ fn pretty(v: &Value) -> String {
/// A number as a person would type it: `6`, `0.5`, `1e+300`.
fn number_text(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
} else if n.is_finite() && (n.abs() >= 1e15 || n.abs() < 1e-5) {
// Rust's `Display` never uses exponent form; `{:e}` omits the `+` on positive exponents.
let s = format!("{n:e}");
match s.split_once('e') {
Some((m, e)) if !e.starts_with('-') => format!("{m}e+{e}"),
_ => s,
}
} else {
format!("{n}")
}
if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") }
}
/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text.
@@ -617,11 +606,6 @@ mod tests {
fn value_texts() {
assert_eq!(value_text(&json!(6.0)), "6");
assert_eq!(value_text(&json!(0.25)), "0.25");
assert_eq!(value_text(&json!(1e300)), "1e+300");
assert_eq!(value_text(&json!(-1.5e300)), "-1.5e+300");
assert_eq!(value_text(&json!(1e-20)), "1e-20");
assert_eq!(value_text(&json!(1e-300)), "1e-300");
assert_eq!(value_text(&json!(0.0001)), "0.0001");
assert_eq!(value_text(&json!(true)), "TRUE");
assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!");
assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx");
+2
View File
@@ -1,3 +1,5 @@
//! The crate's integration tests, one module per file, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod text_to_columns_quotes;
+2 -3
View File
@@ -11,9 +11,8 @@
//! 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`).
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
//! `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
+5 -20
View File
@@ -8,7 +8,6 @@ use wasm_bindgen::JsValue;
const CANVAS_ID: &str = "gridcraft_canvas";
const LOADING_ID: &str = "gridcraft_loading";
/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -61,21 +60,8 @@ pub fn start() {
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// `?chrome=none|notitlebar|nofiletab`: a host that embeds the sheet in its own window and owns the files
// can leave the title bar and/or the File tab undrawn (`Chrome`, #1877). An unknown value keeps
// everything, as the default does.
if let Some(chrome) = query_param("chrome") {
match chrome.as_str() {
"none" => app.chrome = gridcraft_ui_egui::Chrome { title_bar: false, file_tab: false },
"notitlebar" => app.chrome.title_bar = false,
"nofiletab" => app.chrome.file_tab = false,
_ => {}
}
}
// Follow the browser's language, except a CJK one. CJK coverage comes from
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
// cannot be assumed to render localized labels without tofu. Users can still
// choose a language explicitly.
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
let browser = gridcraft_ui_egui::i18n::Language::system();
if !matches!(
browser,
@@ -170,9 +156,8 @@ impl eframe::App for WebShell {
}
}
/// Forwards a browser frame's raw input to the shared app before eframe handles it.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -367,7 +352,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
if let Some(picker_fn) = show_picker_fn {
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase();
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
+2 -11
View File
@@ -13,7 +13,7 @@ workspace = true
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -24,19 +24,10 @@ chrono = { workspace = true }
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { workspace = true }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
rfd = { workspace = true }
arboard = { workspace = true }
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"] }
+1 -2
View File
@@ -43,8 +43,7 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
app.open_path(p);
}
}
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
Event::Quit => app.request_quit(ctx),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
+8 -138
View File
@@ -1,85 +1,40 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
//!
//! The first line of a connection must authenticate with the app's token (#777; see
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
//! JSON, is answered once and closed.
//!
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
/// How long a new connection may take to send its token.
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
/// else 256 random bits from the operating system, written to a file only the user can read.
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
if t.len() < control_auth::MIN_TOKEN_LEN {
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
}
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
return Ok(t);
}
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
let token = control_auth::hex(&bytes);
let path = control_auth::write_token_file(port, &token)?;
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
Ok(token)
}
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let token: Arc<str> = make_token(port, chosen_token)?.into();
let rx = serve_listener(listener, token, ctx)?;
let rx = serve_listener(listener, ctx)?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
})?;
Ok(rx)
}
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
let mut first = String::new();
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
let verdict = match read {
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
_ => Err(json!({"ok": false, "error": "authentication required"})),
};
let allowed = verdict.is_ok();
let reply = verdict.unwrap_or_else(|e| e);
let _ = writeln!(out, "{reply}");
let _ = out.set_read_timeout(None);
allowed
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
@@ -148,13 +103,10 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
if !authenticate(&mut reader, &mut out, token) {
return;
}
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
@@ -202,29 +154,21 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
/// Authenticate a new connection, send `input`, and read reply lines (after the
/// authentication reply) until the server closes it.
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
c.write_all(input).unwrap();
// A read timeout ends the list too: the server kept the connection open.
let mut replies: Vec<Json> = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
replies.remove(0);
replies
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
}
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, TOKEN.into(), egui::Context::default()).unwrap())
(addr, serve_listener(listener, egui::Context::default()).unwrap())
}
/// A browser can POST to the port: its request line closes the connection before the JSON
@@ -250,11 +194,8 @@ mod tests {
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
let mut r = BufReader::new(c);
let mut auth_reply = String::new();
r.read_line(&mut auth_reply).unwrap();
for id in [1, 2] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
@@ -280,75 +221,4 @@ mod tests {
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// A server on a loopback port with a stand-in UI that echoes each method.
fn spawn() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
}
});
addr
}
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(send.as_bytes()).unwrap();
// Replies up to the server closing the connection.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
}
#[test]
fn the_token_opens_the_connection() {
let addr = spawn();
let auth = control_auth::auth_line(1, TOKEN);
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
line.clear();
r.read_line(&mut line).unwrap();
let v: Json = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
}
#[test]
fn anything_but_the_token_first_closes_the_connection() {
let addr = spawn();
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
// The second request is never served: one refusal, then end of stream.
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
assert_eq!(got.len(), 1, "{first}: {got:?}");
assert_eq!(got[0]["ok"], json!(false), "{first}");
}
}
#[test]
fn an_unparsable_line_after_auth_closes_the_connection() {
let addr = spawn();
let auth = control_auth::auth_line(1, TOKEN);
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
assert_eq!(got.len(), 2, "{got:?}");
assert_eq!(got[1]["ok"], json!(false));
}
#[test]
fn a_chosen_token_must_be_long_enough() {
assert!(make_token(1, Some("short".into())).is_err());
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
}
}
+303 -5
View File
@@ -268,10 +268,9 @@ impl log::Log for AppLogger {
}
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
let logged_at = gridcraft_engine::core::date::unix_now_ms()
.and_then(|milliseconds| SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(milliseconds.max(0.0) as u64)))
.unwrap_or(SystemTime::UNIX_EPOCH);
let line = format_line(&timestamp(logged_at), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(line.as_bytes());
@@ -296,4 +295,303 @@ pub fn install() -> Option<&'static AppLogger> {
}
#[cfg(test)]
mod tests;
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
use log::Log;
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("gridcraft"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace);
assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug);
assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"gridcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,gridcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "gridcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"),
"2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2");
assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "gridcraft", "before the settings directory");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Error, "gridcraft", "internal error: boom");
record(&logger, log::Level::Debug, "gridcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("gridcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}"));
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
record(&logger, log::Level::Error, "x", "still fine");
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
record(&logger, log::Level::Warn, "gridcraft", "after the poison");
assert!(read(&path).contains("gridcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
}
-298
View File
@@ -1,298 +0,0 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
use log::Log;
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("gridcraft"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace);
assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug);
assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"gridcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,gridcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "gridcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"),
"2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2");
assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "gridcraft", "before the settings directory");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Error, "gridcraft", "internal error: boom");
record(&logger, log::Level::Debug, "gridcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("gridcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}"));
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
record(&logger, log::Level::Error, "x", "still fine");
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
record(&logger, log::Level::Warn, "gridcraft", "after the poison");
assert!(read(&path).contains("gridcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
+14 -47
View File
@@ -1,10 +1,9 @@
//! GridCraft desktop app.
//!
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
//!
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -23,34 +22,31 @@ use std::cell::RefCell;
use std::rc::Rc;
use gridcraft_engine::Session;
use gridcraft_ui_egui::doc_windows::DocWindows;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events.
struct App(SheetApp, DocWindows, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.2 = Some(native_menu::NativeMenu::install(ctx));
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(ctx));
}
if let Some(m) = &mut self.2 {
m.poll(&mut self.0, ctx, self.1.focused_viewport());
if let Some(m) = &self.1 {
m.poll(&mut self.0, ctx);
}
apple_events::poll(&self.3, &mut self.0, ctx);
apple_events::poll(&self.2, &mut self.0, ctx);
}
self.0.logic(ctx);
self.1.logic(&mut self.0, ctx);
self.0.poll_printing(ctx);
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
}
/// Forwards raw input to the shared app, then opens every non-empty dropped file path as a workbook.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
// Files dropped on the window open as workbooks.
for f in std::mem::take(&mut raw.dropped_files) {
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
@@ -59,7 +55,7 @@ impl eframe::App for App {
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.1.show(&mut self.0, ui);
self.0.ui(ui);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
@@ -184,11 +180,11 @@ fn services() -> Services {
})),
pick_save: Some(Box::new(|suggested: &str| {
let path = std::path::Path::new(suggested);
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
const TYPES: [(&str, &str); 4] =
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
@@ -255,20 +251,11 @@ fn main() -> eframe::Result<()> {
}
}));
let args: Vec<String> = std::env::args().skip(1).collect();
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("GridCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
let mut sample: Option<String> = None;
let mut files: Vec<String> = Vec::new();
let mut i = 0;
@@ -278,10 +265,6 @@ fn main() -> eframe::Result<()> {
i += 1;
control_port = args.get(i).and_then(|p| p.parse().ok());
}
"--control-token" => {
i += 1;
control_token = args.get(i).cloned();
}
"--sample" => {
i += 1;
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
@@ -316,7 +299,6 @@ fn main() -> eframe::Result<()> {
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.start_update_check();
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
@@ -338,20 +320,7 @@ fn main() -> eframe::Result<()> {
if let Some(i) = icon() {
viewport = viewport.with_icon(i);
}
let window_builder = viewport.clone();
let options = eframe::NativeOptions { viewport, ..Default::default() };
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
#[cfg(target_os = "windows")]
@@ -383,15 +352,13 @@ fn main() -> eframe::Result<()> {
};
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
if let Some(port) = control_port {
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
match control_server::start(port, cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => log::error!("control channel on port {port}: {e}"),
}
}
let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window());
Ok(Box::new(App(
app,
windows,
#[cfg(target_os = "macos")]
None,
#[cfg(target_os = "macos")]
+8 -82
View File
@@ -4,7 +4,7 @@
use std::str::FromStr;
use std::sync::mpsc::{Receiver, channel};
use gridcraft_ui_egui::{OpenWindow, SheetApp};
use gridcraft_ui_egui::SheetApp;
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::{Value, json};
@@ -168,20 +168,10 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Calculate Now", "formulas.calculateNow", None),
],
),
// The open workbooks follow the separator (`NativeMenu::sync_windows`).
(
"Window",
vec![
("New Window", "view.newWindow", None),
("Next Sheet", "sheet.next", None),
("Previous Sheet", "sheet.previous", None),
("-", "", None),
],
),
("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]),
(
"Help",
vec![
("Check for Updates…", "app.checkForUpdates", None),
("Agent Control (MCP)…", "dialog:agents", None),
("Contributors…", "dialog:about:Contributors", None),
("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
@@ -195,19 +185,8 @@ pub struct NativeMenu {
_menu: Menu,
events: Receiver<MenuEvent>,
theme_items: Vec<(&'static str, CheckMenuItem)>,
window_menu: Option<Submenu>,
/// The Window menu's window items and what they last showed.
window_items: Vec<CheckMenuItem>,
windows: Vec<OpenWindow>,
}
/// Menu id of the Window menu item that brings workbook `doc` forward.
const WINDOW_ITEM: &str = "window:id:";
/// The Quit item: asks about each unsaved workbook in its window first
/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip.
const QUIT: &str = "app.quit";
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
let mode = match id {
"theme:system" => "system",
@@ -233,7 +212,7 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()),
&PredefinedMenuItem::quit(None),
]);
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
@@ -268,7 +247,7 @@ impl NativeMenu {
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
// the submenu up in the installed main menu.
if let Some(window) = &window_menu {
if let Some(window) = window_menu {
window.set_as_windows_menu_for_nsapp();
}
let (tx, rx) = channel();
@@ -277,54 +256,12 @@ impl NativeMenu {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() }
NativeMenu { _menu: menu, events: rx, theme_items }
}
/// Keeps the Window menu's list of open windows (title, unsaved bullet, active check) in
/// step with the session.
fn sync_windows(&mut self, app: &SheetApp) {
let windows = app.open_windows();
if windows != self.windows
&& let Some(menu) = &self.window_menu
{
let same_windows = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.window == b.window);
if same_windows {
for (item, w) in self.window_items.iter().zip(&windows) {
item.set_text(w.label());
}
} else {
for item in self.window_items.drain(..) {
let _ = menu.remove(&item);
}
for w in &windows {
let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.window), w.label(), true, w.active, None);
let _ = menu.append(&item);
self.window_items.push(item);
}
}
self.windows = windows;
}
// AppKit flips a check item when it is chosen, so the active check is put back every poll.
for (item, w) in self.window_items.iter().zip(&self.windows) {
if item.is_checked() != w.active {
item.set_checked(w.active);
}
}
}
/// Runs the chosen menu items. Commands go to the active window, the most recently focused;
/// `viewport` is its egui viewport, for the items that act on its input.
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) {
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
while let Ok(ev) = self.events.try_recv() {
let id = ev.id.as_ref().to_string();
if id == QUIT {
app.request_quit(ctx);
continue;
}
if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::<u64>().ok()) {
app.run_or_alert("view.switchWindows", json!({"window": window}));
continue;
}
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
if app.text_box_editor.is_some() {
let clipboard = match id.as_str() {
@@ -334,11 +271,11 @@ impl NativeMenu {
_ => None,
};
if let Some(command) = clipboard {
ctx.send_viewport_cmd_to(viewport, command);
ctx.send_viewport_cmd(command);
continue;
}
if id == "edit.undo" || id == "edit.redo" {
ctx.input_mut_for(viewport, |i| {
ctx.input_mut(|i| {
i.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
@@ -384,7 +321,6 @@ impl NativeMenu {
item.set_checked(checked);
}
}
self.sync_windows(app);
}
}
@@ -401,16 +337,6 @@ mod tests {
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
}
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
let tree = tree();
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
assert_eq!(item.1, "app.checkForUpdates");
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
assert!(app.run(item.1, serde_json::json!({})).is_ok());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
+3 -7
View File
@@ -1,15 +1,11 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
@@ -21,7 +17,7 @@ pub fn discover() -> Discover {
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
@@ -49,7 +45,7 @@ pub fn submit() -> Submit {
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
+1
View File
@@ -7,6 +7,7 @@
// unit tests compile here too without a harness to run them).
#[cfg(target_os = "macos")]
#[path = "../src/native_menu.rs"]
#[allow(unused_imports, dead_code)]
mod native_menu;
#[cfg(target_os = "macos")]
-5
View File
@@ -10,8 +10,3 @@ disallowed-methods = [
{ path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" },
{ path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" },
]
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
-4
View File
@@ -99,7 +99,3 @@ real_name = "Tyrel Haveman"
[people.helenkwok]
real_name = "Helen Kwok"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+1419 -121
View File
File diff suppressed because it is too large Load Diff
-180
View File
@@ -1,180 +0,0 @@
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
/// The aggregates that can fold a reference without building an array.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Agg {
Sum,
Count,
Average,
}
impl Agg {
pub(super) fn of(name: &str) -> Option<Agg> {
match name {
"SUM" => Some(Agg::Sum),
"COUNT" => Some(Agg::Count),
"AVERAGE" => Some(Agg::Average),
_ => None,
}
}
}
/// What a folded aggregate carries from one value to the next.
#[derive(Default)]
pub(super) struct Acc {
pub(super) sum: f64,
pub(super) count: u64,
}
impl Acc {
/// Takes a value found inside a reference: only numbers count. `Err` is an error value that
/// ends `SUM` and `AVERAGE`; `COUNT` skips errors.
pub(super) fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> {
match v {
Value::Number(n) => {
self.sum += n;
self.count = self.count.saturating_add(1);
Ok(())
}
Value::Error(e) if kind != Agg::Count => Err(*e),
_ => Ok(()),
}
}
pub(super) fn finish(&self, kind: Agg) -> Value {
match kind {
Agg::Sum => Value::number(self.sum),
Agg::Count => Value::number(self.count as f64),
Agg::Average if self.count == 0 => Value::Error(CellError::Div0),
Agg::Average => Value::number(self.sum / self.count as f64),
}
}
}
// ---------------------------------------------------------------- operators
pub(super) fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value {
match v {
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }),
Value::Error(e) => Value::Error(*e),
Value::Text(_) if plus => v.clone(),
other => match other.to_number_in(sys) {
Ok(n) => f(n),
Err(e) => Value::Error(e),
},
}
}
/// Element-wise combination with Excel's broadcasting.
pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value {
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);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
}
}
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() }),
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }),
(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.
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
}
fn broadcast_dim(a: usize, b: usize) -> usize {
if a == 1 {
b
} else if b == 1 {
a
} else {
a.max(b)
}
}
pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value {
let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) };
let (tr, tc) = dims(t);
let (fr, fc) = dims(f);
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for col in 0..cols {
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
}
}
Value::from(Array { rows, cols, data })
}
/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
pub(super) fn single(v: Value) -> Option<Value> {
match v {
Value::Array(a) if a.rows * a.cols > 1 => None,
v => Some(v.scalar()),
}
}
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
if let Value::Error(e) = a {
return Value::Error(*e);
}
if let Value::Error(e) = b {
return Value::Error(*e);
}
let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) {
(Ok(x), Ok(y)) => f(x, y),
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
use std::cmp::Ordering::*;
match op {
BinOp::Add => num(|x, y| Value::number(x + y)),
BinOp::Sub => num(|x, y| Value::number(x - y)),
BinOp::Mul => num(|x, y| Value::number(x * y)),
BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }),
BinOp::Pow => num(|x, y| {
if x == 0.0 && y == 0.0 {
Value::Error(CellError::Num)
} else if x == 0.0 && y < 0.0 {
Value::Error(CellError::Div0)
} else {
Value::number(x.powf(y))
}
}),
BinOp::Concat => match (a.to_text(), b.to_text()) {
(Ok(x), Ok(y)) => {
if x.len() + y.len() > 32767 * 4 {
Value::Error(CellError::Value)
} else {
Value::text(x + &y)
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
},
BinOp::Eq => Value::Bool(compare(a, b) == Equal),
BinOp::Ne => Value::Bool(compare(a, b) != Equal),
BinOp::Lt => Value::Bool(compare(a, b) == Less),
BinOp::Gt => Value::Bool(compare(a, b) == Greater),
BinOp::Le => Value::Bool(compare(a, b) != Greater),
BinOp::Ge => Value::Bool(compare(a, b) != Less),
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
}
}
-435
View File
@@ -1,435 +0,0 @@
use super::array::{Acc, Agg, zip2, zip3};
use super::format::same_kind;
use super::special::{ev_shape, resized_sum_range, resizes_sum_range};
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {
match name {
"IF" => self.if_call(args),
"IFS" => {
if args.len() < 2 || !args.len().is_multiple_of(2) {
return err(CellError::Value);
}
for pair in args.chunks(2) {
let [c, v] = pair else { break };
let cv = self.value(c);
match cv.scalar().to_bool() {
Ok(true) => return self.eval(v),
Ok(false) => {}
Err(e) => return err(e),
}
}
err(CellError::NA)
}
"IFERROR" | "IFNA" => self.if_error_call(name, args),
"CHOOSE" => self.choose_call(args),
"SWITCH" => self.switch_call(args),
"ROW" | "COLUMN" => self.row_column_call(name, args),
"ROWS" | "COLUMNS" => {
if args.len() != 1 {
return err(CellError::Value);
}
let is_rows = name == "ROWS";
match self.eval(&args[0]) {
Ev::R(a) => match a.as_slice() {
[a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)),
_ => err(CellError::Ref),
},
Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)),
Ev::V(Value::Error(e)) => err(e),
Ev::V(_) => Ev::V(Value::Number(1.0)),
Ev::L(_) => err(CellError::Value),
}
}
"AREAS" => match args.first().map(|a| self.eval(a)) {
Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)),
Some(Ev::V(Value::Error(e))) => err(e),
_ => err(CellError::Value),
},
"ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))),
"ISFORMULA" | "FORMULATEXT" => {
let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) };
let Some(a) = a.first() else { return err(CellError::NA) };
let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone());
if name == "ISFORMULA" {
Ev::V(Value::Bool(f.is_some()))
} else {
match f {
Some(f) => Ev::V(Value::text(format!("={}", f.text))),
None => err(CellError::NA),
}
}
}
"SHEET" | "SHEETS" => self.sheet_call(name, args),
"OFFSET" => self.offset(args),
"INDIRECT" => self.indirect(args),
"INDEX" => self.index(args),
"XLOOKUP" => self.xlookup(args),
"CELL" => self.cell_info(args),
"TRIMRANGE" => self.trimrange(args),
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
"TEXT" => {
if args.len() != 2 {
return err(CellError::Value);
}
let v = self.arg_val(args, 0).unwrap_or_default();
let f = self.arg_val(args, 1).unwrap_or_default();
let sys = self.host.workbook().date_system;
let one = |v: &Value, f: &Value| -> Value {
if let Value::Error(e) = v {
return Value::Error(*e);
}
let code = match f.to_text() {
Ok(c) => c,
Err(e) => return Value::Error(e),
};
match gridcraft_numfmt::text_function(v, &code, sys) {
Ok(s) => Value::text(s),
Err(e) => Value::Error(e),
}
};
Ev::V(zip2(&v, &f, one))
}
"LET" => self.let_(args),
"LAMBDA" => {
if args.is_empty() {
return err(CellError::Value);
}
let mut params = Vec::new();
for p in &args[..args.len() - 1] {
match p {
Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()),
_ => return err(CellError::Value),
}
}
let Some(body) = args.last() else { return err(CellError::Value) };
Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() }))
}
"ISOMITTED" => {
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
Ev::V(Value::Bool(r))
}
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
"GROUPBY" | "PIVOTBY" => {
// The aggregation may be a function given by name (`SUM`): pass it as its name.
let i = if name == "GROUPBY" { 2 } else { 3 };
match args.get(i).and_then(|a| self.function_by_name(a)) {
Some(f) => {
let mut args = args.to_vec();
if let Some(a) = args.get_mut(i) {
*a = Expr::Text(f.into());
}
self.builtin(name, &args)
}
None => self.builtin(name, args),
}
}
"NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => {
self.host.note_volatile();
self.builtin(name, args)
}
_ => self.builtin(name, args),
}
}
fn if_call(&mut self, args: &[Expr]) -> Ev {
if args.is_empty() || args.len() > 3 {
return err(CellError::Value);
}
let condition = self.arg_val(args, 0).unwrap_or_default();
if let Value::Array(array) = &condition {
let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() {
Ok(true) => when_true.clone(),
Ok(false) => when_false.clone(),
Err(error) => Value::Error(error),
}));
}
match condition.to_bool() {
Ok(true) => match args.get(1) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(expression) => self.eval(expression),
None => Ev::V(Value::Bool(true)),
},
Ok(false) => match args.get(2) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(expression) => self.eval(expression),
None => Ev::V(Value::Bool(false)),
},
Err(error) => err(error),
}
}
fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev {
if args.len() != 2 {
return err(CellError::Value);
}
let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty));
let na_only = name == "IFNA";
let catches = |error: CellError| !na_only || error == CellError::NA;
match &value {
Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]),
Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
let alternate = self.arg_val(args, 1).unwrap_or_default();
let data =
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
Ev::R(_) => self.if_error_reference(value.clone(), args, catches),
_ => value,
}
}
fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev {
match self.deref(value.clone()) {
Value::Error(error) if catches(error) => self.eval(&args[1]),
Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
let alternate = self.arg_val(args, 1).unwrap_or_default();
let data =
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
_ => value,
}
}
fn choose_call(&mut self, args: &[Expr]) -> Ev {
if args.len() < 2 {
return err(CellError::Value);
}
let index = self.value(&args[0]);
if let Value::Array(_) = index {
let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) };
let mut evaluated = vec![Arg { value: index, from_ref: false }];
for argument in &args[1..] {
evaluated.push(Arg { value: self.value(argument), from_ref: false });
}
let mut context = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V);
}
let index = match index.to_number() {
Ok(number) => number.trunc() as i64,
Err(error) => return err(error),
};
if index < 1 || index as usize >= args.len() {
return err(CellError::Value);
}
self.eval(&args[index as usize])
}
fn switch_call(&mut self, args: &[Expr]) -> Ev {
if args.len() < 3 {
return err(CellError::Value);
}
let value = self.arg_val(args, 0).unwrap_or_default().scalar();
if let Value::Error(error) = value {
return err(error);
}
let choices = &args[1..];
for pair in choices.as_chunks::<2>().0 {
let (candidate, result) = (&pair[0], &pair[1]);
let candidate = self.value(candidate).scalar();
if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) {
return self.eval(result);
}
}
if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) {
self.eval(default)
} else {
err(CellError::NA)
}
}
fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev {
let is_row = name == "ROW";
if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) {
return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64));
}
let areas = match self.eval(&args[0]) {
Ev::R(areas) => areas,
Ev::V(Value::Error(error)) => return err(error),
_ => return err(CellError::Value),
};
let Some(area) = areas.first() else { return err(CellError::Ref) };
let range = self.trim_lines(area);
if is_row {
let values: Vec<Value> = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect();
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) }
} else {
let values: Vec<Value> = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect();
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) }
}
}
fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev {
let is_sheet = name == "SHEET";
if args.is_empty() {
let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() };
return Ev::V(Value::number(count as f64));
}
match self.eval(&args[0]) {
Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))),
Ev::R(areas) => {
let mut sheets: Vec<usize> = areas.iter().map(|area| area.sheet).collect();
sheets.dedup();
Ev::V(Value::number(sheets.len() as f64))
}
Ev::V(Value::Text(name)) if is_sheet => {
self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0)))
}
_ => err(CellError::NA),
}
}
pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
let Some(spec) = gridcraft_functions::lookup(name) else {
// A defined name holding a LAMBDA, or a LET-bound lambda.
return match self.name(name) {
Ev::L(l) => self.apply_lambda(&l, args),
Ev::V(Value::Error(x)) => err(x),
_ => err(CellError::Name),
};
};
if args.len() < spec.min_args || args.len() > spec.max_args {
return err(CellError::Value);
}
if let Some(kind) = Agg::of(spec.name) {
return self.aggregate(kind, args);
}
let mut evaluated = Vec::with_capacity(args.len());
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
let mut criteria_shape = None;
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
let mut criteria = None;
// GROUPBY/PIVOTBY: the LAMBDA given as their function.
let mut lambda = None;
let lambda_at = match name {
"GROUPBY" => Some(2),
"PIVOTBY" => Some(3),
_ => None,
};
for (i, a) in args.iter().enumerate() {
let mut ev = self.eval(a);
if lambda_at == Some(i)
&& let Ev::L(l) = &ev
{
lambda = Some(l.clone());
ev = Ev::V(Value::Empty);
}
if spec.category == gridcraft_functions::Category::Database
&& i == 2
&& let Ev::R(areas) = &ev
&& let [area] = areas.as_slice()
{
criteria = Some(*area);
}
if resizes_sum_range(name, args.len()) {
if i == 0 {
criteria_shape = ev_shape(&ev);
} else if i == 2
&& let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev)
&& let [area] = areas.as_slice()
{
ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]);
}
}
let from_ref = matches!(ev, Ev::R(_));
let value = match ev {
Ev::R(areas) if areas.len() > 1 => {
// Multi-area references (unions): flatten into one row for aggregates.
let mut vals = Vec::new();
for ar in &areas {
match self.area_value(ar) {
Value::Array(a) => vals.extend(a.data.iter().cloned()),
v => vals.push(v),
}
}
Value::from(Array::row(vals))
}
ev => self.deref(ev),
};
evaluated.push(Arg { value, from_ref });
}
let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
/// Performs aggregation operations without copying its ranges: blocks are folded over the stored
/// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one
/// after another, as the array path does, so volatile formulas (like `RAND`) see the same order.
/// Only `SUM`, `COUNT` and `AVG` are supported currently.
fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev {
let mut acc = Acc::default();
let mut first_error = None;
for a in args {
let ev = self.eval(a);
let steps = match ev {
Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(),
ev => {
let value = self.deref(ev);
vec![self.aggregate_value(kind, value, false, &mut acc)]
}
};
if first_error.is_none() {
first_error = steps.into_iter().find_map(Result::err);
}
}
first_error.unwrap_or_else(|| Ev::V(acc.finish(kind)))
}
/// 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() {
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
Err(e) => {
error = Some(e);
false
}
});
if walked {
return error.map_or(Ok(()), |e| Err(err(e)));
}
}
let value = self.area_value(a);
self.aggregate_value(kind, value, true, acc)
}
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
return Err(err(CellError::Name));
};
let args = [Arg { value, from_ref }];
if kind != Agg::Count {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.sum += n,
other => return Err(Ev::V(other)),
}
}
if kind != Agg::Sum {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
other => return Err(Ev::V(other)),
}
}
Ok(())
}
/// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`,
/// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has
/// that name.
pub(super) fn function_by_name(&self, e: &Expr) -> Option<String> {
let Expr::Name(n) = e else { return None };
if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() {
return None;
}
let u = n.to_ascii_uppercase();
gridcraft_functions::lookup(&u).map(|_| u)
}
}
-75
View File
@@ -1,75 +0,0 @@
use super::{CellError, Value};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
/// CELL("format") code for a number format.
pub(super) fn cell_format_code(code: &str) -> String {
let c = code.to_ascii_lowercase();
if c == "general" {
return "G".into();
}
if c.contains('%') {
return format!("P{}", decimals(&c));
}
if c.contains('e') && c.contains('0') {
return format!("S{}", decimals(&c));
}
if c.contains('$') {
return format!("C{}", decimals(&c));
}
if c.contains('y') || c.contains('d') {
return "D1".into();
}
if c.contains('h') {
return "D9".into();
}
if c.contains(',') {
return format!(",{}", decimals(&c));
}
format!("F{}", decimals(&c))
}
fn decimals(c: &str) -> usize {
c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count())
}
pub(super) fn same_kind(a: &Value, b: &Value) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty())
}
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is
/// not R1C1; `Err` means it is R1C1 but points past the sheet.
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result<Option<String>, CellError> {
let (sheet, body) = match s.rsplit_once('!') {
Some((sh, b)) => (format!("{sh}!"), b),
None => (String::new(), s),
};
let conv = |part: &str| -> Result<Option<String>, CellError> {
let u = part.to_ascii_uppercase();
let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) };
// `None`: not R1C1. `Some(None)`: R1C1, but past the sheet.
let num = |p: &str, base: u32, max: u32| -> Option<Option<(u32, bool)>> {
let (v, absolute) = if p.is_empty() {
(Some(i64::from(base)), false)
} else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
(i64::from(base).checked_add(inner.parse().ok()?), false)
} else {
(p.parse::<u32>().ok()?.checked_sub(1).map(i64::from), true)
};
Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute)))
};
let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) };
let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) };
Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)))
};
let out = match body.split_once(':') {
Some((a, b)) => match (conv(a)?, conv(b)?) {
(Some(a), Some(b)) => format!("{a}:{b}"),
_ => return Ok(None),
},
None => match conv(body)? {
Some(x) => x,
None => return Ok(None),
},
};
Ok(Some(format!("{sheet}{out}")))
}
-200
View File
@@ -1,200 +0,0 @@
use super::MAX_LAMBDA_CALLS;
use super::array::{broadcast_len, single};
use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
// Too many or too few argument positions is #VALUE!. An explicitly empty position, as in
// `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()`
// parses to no arguments, which for a one-parameter lambda can also be read as one empty
// position, so that call still binds the parameter as omitted.
let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1);
if !positions_ok {
return err(CellError::Value);
}
let mut bound = Vec::with_capacity(l.params.len());
for (i, p) in l.params.iter().enumerate() {
let v = match args.get(i) {
Some(Expr::Missing) | None => Ev::V(Value::Empty),
Some(e) => self.eval(e),
};
bound.push((p.clone(), v));
}
self.call_lambda_values(l, bound)
}
pub(super) fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
if self.calls >= MAX_LAMBDA_CALLS {
return err(CellError::Num);
}
// #2382: one charge per invocation, so a cell's total work stays bounded however the
// lambdas nest. Foregrounding the charge here means every path into a body (direct
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
self.fuel = match self.fuel.checked_sub(1) {
Some(f) => f,
None => return err(CellError::Calc),
};
self.calls += 1;
let saved = std::mem::replace(&mut self.env, l.env.clone());
self.env.extend(bound);
let r = self.eval(&l.body);
self.env = saved;
self.calls -= 1;
r
}
pub(super) fn let_(&mut self, args: &[Expr]) -> Ev {
if args.len() < 3 || args.len().is_multiple_of(2) {
return err(CellError::Value);
}
let base = self.env.len();
for pair in args[..args.len() - 1].chunks(2) {
let [Expr::Name(n), v] = pair else {
self.env.truncate(base);
return err(CellError::Value);
};
let val = self.eval(v);
self.env.push((n.clone(), val));
}
let r = match args.last() {
Some(body) => self.eval(body),
None => err(CellError::Value),
};
self.env.truncate(base);
r
}
/// The function argument of a LAMBDA helper; a function given by name takes `arity`
/// arguments.
fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option<Arc<Lambda>> {
if let Some(f) = e.and_then(|x| self.function_by_name(x)) {
let params: Vec<String> = (0..arity).map(|i| format!("_xleta.{i}")).collect();
let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect());
return Some(Arc::new(Lambda { params, body, env: Vec::new() }));
}
match e.map(|x| self.eval(x)) {
Some(Ev::L(l)) => Some(l),
_ => None,
}
}
pub(super) fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev {
let as_array = |v: Value| -> Arc<Array> {
match v {
Value::Array(a) => a,
other => Arc::new(Array::scalar(other)),
}
};
match name {
"MAP" => {
if args.len() < 2 {
return err(CellError::Value);
}
let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) };
let arrays: Vec<Arc<Array>> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) };
// The LAMBDA takes one parameter per array.
if l.params.len() != arrays.len() {
return err(CellError::Value);
}
let mut out = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect();
let v = self.call_lambda_values(&l, bound);
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
}
"REDUCE" | "SCAN" => {
if args.len() != 3 {
return err(CellError::Value);
}
let mut acc = self.value(&args[0]);
let arr = as_array(self.value(&args[1]));
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
if l.params.len() != 2 {
return err(CellError::Value);
}
let mut scan = Vec::with_capacity(arr.data.len());
for v in arr.data.iter() {
let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))];
let r = self.call_lambda_values(&l, bound);
acc = self.deref(r);
if name == "SCAN" {
let Some(one) = single(acc.clone()) else { return err(CellError::Calc) };
scan.push(one);
}
}
if name == "SCAN" {
Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
} else {
Ev::V(acc)
}
}
"BYROW" | "BYCOL" => {
if args.len() != 2 {
return err(CellError::Value);
}
let arr = as_array(self.value(&args[0]));
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
if l.params.len() != 1 {
return err(CellError::Value);
}
let by_row = name == "BYROW";
let n = if by_row { arr.rows } else { arr.cols };
let mut out = Vec::with_capacity(n);
for i in 0..n {
let slice: Vec<Value> = if by_row {
(0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect()
} else {
(0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect()
};
let a = if by_row { Array::row(slice) } else { Array::column(slice) };
let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]);
let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) };
out.push(one);
}
let a = if by_row { Array::column(out) } else { Array::row(out) };
Ev::V(a.into())
}
"MAKEARRAY" => {
if args.len() != 3 {
return err(CellError::Value);
}
let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) {
(Ok(r), Ok(c)) => (r.trunc(), c.trunc()),
(Err(e), _) | (_, Err(e)) => return err(e),
};
if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 {
return err(CellError::Value);
}
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
if l.params.len() != 2 {
return err(CellError::Value);
}
let (rows, cols) = (rows as usize, cols as usize);
let mut out = Vec::with_capacity(rows * cols);
for r in 0..rows {
for c in 0..cols {
let bound = vec![
(l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))),
(l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))),
];
let v = self.call_lambda_values(&l, bound);
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
}
_ => err(CellError::Name),
}
}
}
-409
View File
@@ -1,409 +0,0 @@
use super::format::{cell_format_code, r1c1_to_a1};
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
pub(super) fn trim_lines(&mut self, a: &Area) -> RangeRef {
if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range }
}
pub(super) fn offset(&mut self, args: &[Expr]) -> Ev {
self.host.note_volatile();
if args.len() < 3 || args.len() > 5 {
return err(CellError::Value);
}
let base = match self.eval(&args[0]) {
Ev::R(a) if a.len() == 1 => a[0],
Ev::V(Value::Error(e)) => return err(e),
_ => return err(CellError::Value),
};
let rows = match self.arg_num(args, 1, 0.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let cols = match self.arg_num(args, 2, 0.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let h = match self.arg_num(args, 3, base.range.height() as f64) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let w = match self.arg_num(args, 4, base.range.width() as f64) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
if h == 0 || w == 0 {
return err(CellError::Ref);
}
let r0 = (base.range.start.row as i64).saturating_add(rows);
let c0 = (base.range.start.col as i64).saturating_add(cols);
let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) };
let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) };
if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 {
return err(CellError::Ref);
}
Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }])
}
pub(super) fn indirect(&mut self, args: &[Expr]) -> Ev {
self.host.note_volatile();
if args.is_empty() || args.len() > 2 {
return err(CellError::Value);
}
let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t,
Some(Err(e)) => return err(e),
None => return err(CellError::Value),
};
let a1 = match args.get(1) {
None | Some(Expr::Missing) => true,
Some(e) => {
let v = self.value(e);
v.scalar().to_bool().unwrap_or(true)
}
};
// With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference.
let (text, a1_ok) = if a1 {
(text, true)
} else {
match r1c1_to_a1(&text, self.at) {
Ok(Some(a1_text)) => (a1_text, true),
Ok(None) => (text, false),
Err(e) => return err(e),
}
};
match gridcraft_formula::parse(&text) {
Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref),
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
r @ Ev::R(_) => r,
_ => err(CellError::Ref),
},
_ => err(CellError::Ref),
}
}
pub(super) fn index(&mut self, args: &[Expr]) -> Ev {
if args.is_empty() || args.len() > 4 {
return err(CellError::Value);
}
let src = self.eval(&args[0]);
let mut arg = |i: usize| match args.get(i) {
None | Some(Expr::Missing) => None,
Some(e) => Some(self.value(e)),
};
let (rows, cols) = (arg(1), arg(2));
let area_n = match self.arg_num(args, 3, 1.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let two = args.len() == 2;
let num = |v: &Option<Value>| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64);
// Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2.
if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) {
let as_array = |v: &Option<Value>| match v {
Some(Value::Array(a)) => Arc::clone(a),
Some(v) => Arc::new(Array::scalar(v.clone())),
None => Arc::new(Array::scalar(Value::Number(0.0))),
};
let (ra, ca) = (as_array(&rows), as_array(&cols));
let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols));
if (h as u64).saturating_mul(w as u64) > MAX_CELLS {
return err(CellError::Num);
}
let mut out = Vec::with_capacity(h * w);
for r in 0..h {
for c in 0..w {
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
let v = match pos {
(Ok(row), Ok(col)) => {
let one = self.index_one(src.clone(), row, col, area_n, two);
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
out.push(v);
}
}
return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value)));
}
let (row, col) = match (num(&rows), num(&cols)) {
(Ok(r), Ok(c)) => (r, c),
(Err(e), _) | (_, Err(e)) => return err(e),
};
self.index_one(src, row, col, area_n, two)
}
/// INDEX for one row and column position (0 = the whole column or row).
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
if row < 0 || col < 0 {
return err(CellError::Value);
}
match src {
Ev::R(areas) => {
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
let r = a.range;
// A single row or column with one index picks along it. An area with several rows
// and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third
// argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ:
// `INDEX({1,2;3,4},2)` is the row.
if two_args && r.height() > 1 && r.width() > 1 {
return err(CellError::Ref);
}
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
return err(CellError::Ref);
}
let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) };
let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) };
Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }])
}
Ev::V(Value::Array(arr)) => {
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
if row as usize > arr.rows || col as usize > arr.cols {
return err(CellError::Ref);
}
match (row, col) {
(0, 0) => Ev::V(Value::Array(arr)),
(0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()),
(r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()),
(r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))),
}
}
Ev::V(Value::Error(e)) => err(e),
Ev::V(v) => {
if row <= 1 && col <= 1 {
Ev::V(v)
} else {
err(CellError::Ref)
}
}
Ev::L(_) => err(CellError::Value),
}
}
/// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can
/// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of
/// lookup values, array arguments, errors) goes to the library function.
pub(super) fn xlookup(&mut self, args: &[Expr]) -> Ev {
if (3..=6).contains(&args.len())
&& let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2]))
&& let ([l], [r]) = (look.as_slice(), ret.as_slice())
&& let Some(spec) = gridcraft_functions::lookup("XMATCH")
{
let key = self.value(&args[0]);
let (l, r) = (*l, *r);
let vertical = l.range.width() == 1 && l.range.height() > 1;
let horizontal = l.range.height() == 1 && l.range.width() > 1;
let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() };
if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len {
let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }];
// An empty match_mode before a given search_mode is the default (0), not the end.
let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing));
let last = if given(5) {
5
} else if given(4) {
4
} else {
3
};
for i in 4..=last {
match args.get(i) {
Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }),
Some(e) => {
let value = self.value(e);
xargs.push(Arg { value, from_ref: false });
}
}
}
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
Value::Number(p) if p >= 1.0 => {
let k = p as u32 - 1;
let range = if vertical {
RangeRef::new(
CellRef::new(r.range.start.row + k, r.range.start.col),
CellRef::new(r.range.start.row + k, r.range.end.col),
)
} else {
RangeRef::new(
CellRef::new(r.range.start.row, r.range.start.col + k),
CellRef::new(r.range.end.row, r.range.start.col + k),
)
};
Ev::R(vec![Area { sheet: r.sheet, range }])
}
Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]),
Value::Error(e) => err(e),
_ => err(CellError::Value),
};
}
}
self.builtin("XLOOKUP", args)
}
pub(super) fn cell_info(&mut self, args: &[Expr]) -> Ev {
let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t.to_ascii_lowercase(),
Some(Err(e)) => return err(e),
None => return err(CellError::Value),
};
let area = match args.get(1).map(|a| self.eval(a)) {
Some(Ev::R(a)) => a.first().copied(),
None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }),
_ => return err(CellError::Value),
};
let Some(a) = area else { return err(CellError::Ref) };
let c = a.range.start;
let wb = self.host.workbook();
let sheet = wb.sheet(a.sheet);
match kind.as_str() {
"address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))),
"row" => Ev::V(Value::number(c.row as f64 + 1.0)),
"col" => Ev::V(Value::number(c.col as f64 + 1.0)),
"contents" => Ev::V(self.host.cell_value(a.sheet, c)),
"type" => {
let v = self.host.cell_value(a.sheet, c);
Ev::V(Value::text(match v {
Value::Empty => "b",
Value::Text(_) => "l",
_ => "v",
}))
}
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
// Excel's `<folder>/[<file>]<sheet>`, which the classic sheet-name formula splits on
// the `]`. Empty until the workbook has a file of its own, as in Excel (#1489).
"filename" => Ev::V(Value::text(match &wb.source_path {
Some(file) => {
let name = file.file_name().unwrap_or_default().to_string_lossy();
let on = sheet.map_or("", |s| s.name.as_str());
file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned()
}
None => String::new(),
})),
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
"format" => {
let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default();
Ev::V(Value::text(cell_format_code(&code)))
}
"color" | "parentheses" => Ev::V(Value::Number(0.0)),
"prefix" => Ev::V(Value::text("")),
_ => err(CellError::Value),
}
}
pub(super) fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev {
// SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1.
let min_args = if name == "AGGREGATE" { 3 } else { 2 };
if args.len() < min_args {
return err(CellError::Value);
}
let code = match self.arg_num(args, 0, 0.0) {
Ok(n) => n.trunc() as u32,
Err(e) => return err(e),
};
let (func, options, rest) = if name == "AGGREGATE" {
let opt = match self.arg_num(args, 1, 0.0) {
Ok(n) => n.trunc() as u32,
Err(e) => return err(e),
};
(code, opt, &args[2..])
} else {
let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) };
(f, if ignore_hidden { 5 } else { 0 }, &args[1..])
};
if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) {
return err(CellError::Value);
}
let skip_hidden = matches!(options, 1 | 3 | 5 | 7);
// SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's
// SUBTOTAL documentation); 101-111 also leave out rows hidden by hand.
let skip_filtered = name == "SUBTOTAL";
let skip_errors = matches!(options, 2 | 3 | 6 | 7);
// SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3.
let skip_nested = name == "SUBTOTAL" || options <= 3;
let mut values = Vec::new();
let mut k = None;
let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest };
if name == "AGGREGATE" && func >= 14 {
k = rest.get(1).map(|e| self.value(e));
}
// Every cell visited (even one skipped as hidden, nested or an error) and every value
// counts against one budget for the whole call. All arguments are evaluated (references
// stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes
// `A:XFD` trim to the whole sheet, ~17 billion cells (#2175).
enum Input {
Cells(usize, RangeRef),
Values(Value),
}
let mut inputs = Vec::new();
let mut budget = MAX_CELLS;
for e in refs {
match self.eval(e) {
Ev::R(areas) => {
for a in areas {
let range = self.trim(&a);
budget = match budget.checked_sub(range.count()) {
Some(left) => left,
None => return err(CellError::Num),
};
inputs.push(Input::Cells(a.sheet, range));
}
}
Ev::V(v) => {
let n = match &v {
Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX),
_ => 1,
};
budget = match budget.checked_sub(n) {
Some(left) => left,
None => return err(CellError::Num),
};
inputs.push(Input::Values(v));
}
Ev::L(_) => return err(CellError::Value),
}
}
for input in inputs {
match input {
Input::Cells(sheet, range) => {
for c in range.iter() {
let (hidden, nested) = {
let wb = self.host.workbook();
let s = wb.sheet(sheet);
let hidden =
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
let nested = skip_nested
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
let u = f.text.to_ascii_uppercase();
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
});
(hidden, nested)
};
if hidden || nested {
continue;
}
let v = self.host.cell_value(sheet, c);
if skip_errors && v.is_error() {
continue;
}
values.push(v);
}
}
Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
Input::Values(v) => values.push(v),
}
}
if let Some(k) = k {
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k));
}
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
}
-165
View File
@@ -1,165 +0,0 @@
use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool {
args == 3 && matches!(name, "SUMIF" | "AVERAGEIF")
}
/// Height and width of a single-area reference or an array argument.
pub(super) fn ev_shape(ev: &Ev) -> Option<(u32, u32)> {
match ev {
Ev::R(areas) => match areas.as_slice() {
[a] => Some((a.range.height(), a.range.width())),
_ => None,
},
Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))),
Ev::V(_) => Some((1, 1)),
Ev::L(_) => None,
}
}
/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns
/// as the criteria range (`height` × `width`), cut off at the sheet's edge.
pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef {
let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1);
let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1);
RangeRef::new(sum.start, CellRef::new(row, col))
}
/// Collects the static references of an expression with their sheets resolved; `dynamic` is
/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change.
pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
let mut out = Vec::new();
let mut dynamic = false;
let push = |out: &mut Vec<Area>, r: &Reference, range: RangeRef| match &r.sheet {
SheetSel::Current => out.push(Area { sheet, range }),
SheetSel::Named(n) => {
if let Some(i) = wb.sheet_index(n) {
out.push(Area { sheet: i, range });
}
}
SheetSel::Span(a, b) => {
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
for s in i.min(j)..=i.max(j) {
out.push(Area { sheet: s, range });
}
}
}
};
e.walk(&mut |x| match x {
Expr::Ref(r) => push(&mut out, r, r.range()),
// The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when
// the criteria range's size isn't known statically, recalculate the formula every time.
Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) {
(Some(Expr::Ref(c)), Some(Expr::Ref(s))) => {
let (crit, sum) = (c.range(), s.range());
push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width()));
}
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
_ => dynamic = true,
},
Expr::Call(n, _)
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
{
dynamic = true
}
Expr::Name(_) | Expr::Struct(_) => dynamic = true,
Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true,
// A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between
// the references inside it, not only those references.
Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => {
let mut inner = precedents(wb, sheet, a).0;
inner.extend(precedents(wb, sheet, b).0);
let mut boxes: Vec<Area> = Vec::new();
for area in inner {
match boxes.iter_mut().find(|x| x.sheet == area.sheet) {
Some(x) => x.range = x.range.union(&area.range),
None => boxes.push(area),
}
}
out.extend(boxes);
}
_ => {}
});
(out, dynamic)
}
fn is_special(n: &str) -> bool {
matches!(
n,
"IF" | "IFS"
| "IFERROR"
| "IFNA"
| "CHOOSE"
| "SWITCH"
| "ROW"
| "COLUMN"
| "ROWS"
| "COLUMNS"
| "AREAS"
| "ISREF"
| "ISFORMULA"
| "FORMULATEXT"
| "SHEET"
| "SHEETS"
| "OFFSET"
| "INDIRECT"
| "INDEX"
| "CELL"
| "TRIMRANGE"
| "SUBTOTAL"
| "AGGREGATE"
| "TEXT"
| "LET"
| "LAMBDA"
| "ISOMITTED"
| "MAP"
| "REDUCE"
| "SCAN"
| "BYROW"
| "BYCOL"
| "MAKEARRAY"
)
}
/// Names of functions the evaluator implements itself (merged into the function list for UI).
pub const SPECIAL_FUNCTIONS: &[&str] = &[
"IF",
"IFS",
"IFERROR",
"IFNA",
"CHOOSE",
"SWITCH",
"ROW",
"COLUMN",
"ROWS",
"COLUMNS",
"AREAS",
"ISREF",
"ISFORMULA",
"FORMULATEXT",
"SHEET",
"SHEETS",
"OFFSET",
"INDIRECT",
"INDEX",
"CELL",
"TRIMRANGE",
"SUBTOTAL",
"AGGREGATE",
"TEXT",
"LET",
"LAMBDA",
"ISOMITTED",
"MAP",
"REDUCE",
"SCAN",
"BYROW",
"BYCOL",
"MAKEARRAY",
];
pub fn is_known_function(n: &str) -> bool {
is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some()
}
+957 -225
View File
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
use super::RangeRef;
use crate::eval::Area;
use gridcraft_core::CellRef;
use std::collections::BTreeSet;
use std::hash::{BuildHasherDefault, Hasher};
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
#[derive(Default, Clone, Copy)]
pub struct FxHasher(u64);
impl Hasher for FxHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.write_u64(*b as u64);
}
}
fn write_u64(&mut self, i: u64) {
self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
}
fn write_u32(&mut self, i: u32) {
self.write_u64(i as u64);
}
fn write_usize(&mut self, i: usize) {
self.write_u64(i as u64);
}
}
pub(super) type HashMap<K, V> = std::collections::HashMap<K, V, BuildHasherDefault<FxHasher>>;
pub(super) type HashSet<K> = std::collections::HashSet<K, BuildHasherDefault<FxHasher>>;
pub type Key = (usize, CellRef);
#[derive(Clone, Debug, Default)]
pub(super) struct Node {
pub(super) areas: Vec<Area>,
pub(super) dynamic: bool,
}
/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones.
const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
/// `a..=b`?" in O(log n + k).
///
/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query
/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one
/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops
/// dead entries once either grows, so mutations cost O(log n) amortised.
#[derive(Clone, Debug)]
pub(super) struct Intervals<T> {
/// Sorted, without duplicates.
items: Vec<(u32, u32, T)>,
dead: Vec<bool>,
dead_count: usize,
/// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at
/// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included).
max_end: Vec<u32>,
/// Inserted since the last merge.
pending: BTreeSet<(u32, u32, T)>,
}
impl<T> Default for Intervals<T> {
fn default() -> Self {
Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() }
}
}
impl<T: Copy + Ord> Intervals<T> {
pub(super) fn is_empty(&self) -> bool {
self.items.len() == self.dead_count && self.pending.is_empty()
}
pub(super) fn insert(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& *d
{
*d = false;
self.dead_count -= 1;
}
return;
}
self.pending.insert(it);
}
pub(super) fn remove(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& !*d
{
*d = true;
self.dead_count += 1;
}
return;
}
self.pending.remove(&it);
}
/// Merges pending insertions and drops dead entries when there are enough of them.
pub(super) fn settle(&mut self) {
if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) {
return;
}
let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len());
all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it));
all.extend(std::mem::take(&mut self.pending));
// Two sorted runs: the stable sort merges them in linear time.
all.sort();
all.dedup();
let size = all.len().next_power_of_two();
let mut max_end = vec![0; 2 * size];
if let Some(leaves) = max_end.get_mut(size..) {
for (slot, it) in leaves.iter_mut().zip(&all) {
*slot = it.1;
}
}
for i in (1..size).rev() {
let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0));
if let Some(slot) = max_end.get_mut(i) {
*slot = m;
}
}
self.dead = vec![false; all.len()];
self.dead_count = 0;
self.items = all;
self.max_end = max_end;
}
/// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the
/// number of tree nodes visited.
pub(super) fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize {
for it in &self.pending {
if it.0 <= b && it.1 >= a {
f(&it.2);
}
}
// Entries before `p` start at or before row `b`.
let p = self.items.partition_point(|it| it.0 <= b);
if p == 0 {
return 0;
}
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
}
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;
}
if hi - lo <= 1 {
if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo))
&& it.1 >= a
{
f(&it.2);
}
return 1;
}
let mid = lo + (hi - lo) / 2;
1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f)
}
}
#[derive(Clone, Debug, Default)]
pub struct Graph {
pub(super) nodes: HashMap<Key, Node>,
/// single cell → dependents
cell_deps: HashMap<Key, HashSet<Key>>,
/// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column
col_deps: HashMap<(usize, u32), Intervals<Key>>,
/// sheet → row intervals of wider ranges, with their columns: (first, last, dependent)
wide_deps: HashMap<usize, Intervals<(u32, u32, Key)>>,
pub(super) dynamic: HashSet<Key>,
/// Range indexes changed since the last [`Graph::settle`].
unsettled_cols: HashSet<(usize, u32)>,
unsettled_wide: HashSet<usize>,
}
/// A formula's precedents split by shape: single cells, column intervals and wide ranges.
type Entries = (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>);
impl Graph {
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Index entries of a formula's precedents, without duplicates: single cells, column
/// intervals and wide ranges.
fn entries(areas: &[Area]) -> Entries {
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
for a in areas {
if a.range.is_single() {
cells.push((a.sheet, a.range.start));
} else if a.range.width() > WIDE {
wide.push((a.sheet, a.range));
} else {
for c in a.range.start.col..=a.range.end.col {
cols.push((a.sheet, c, a.range.start.row, a.range.end.row));
}
}
}
cells.sort_unstable();
cells.dedup();
cols.sort_unstable();
cols.dedup();
wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end));
wide.dedup();
(cells, cols, wide)
}
pub(super) fn insert(&mut self, k: Key, node: Node) {
self.remove(k);
let (cells, cols, wide) = Self::entries(&node.areas);
for c in cells {
self.cell_deps.entry(c).or_default().insert(k);
}
for (s, c, r0, r1) in cols {
self.col_deps.entry((s, c)).or_default().insert(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
for (s, r) in wide {
self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
if node.dynamic {
self.dynamic.insert(k);
}
self.nodes.insert(k, node);
}
pub(super) fn remove(&mut self, k: Key) {
let Some(old) = self.nodes.remove(&k) else { return };
let (cells, cols, wide) = Self::entries(&old.areas);
for c in cells {
if let Some(v) = self.cell_deps.get_mut(&c) {
v.remove(&k);
if v.is_empty() {
self.cell_deps.remove(&c);
}
}
}
for (s, c, r0, r1) in cols {
if let Some(v) = self.col_deps.get_mut(&(s, c)) {
v.remove(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
}
for (s, r) in wide {
if let Some(v) = self.wide_deps.get_mut(&s) {
v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
}
self.dynamic.remove(&k);
}
/// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it,
/// just slower).
pub(super) fn settle(&mut self) {
for key in std::mem::take(&mut self.unsettled_cols) {
if let Some(v) = self.col_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.col_deps.remove(&key);
}
}
}
for key in std::mem::take(&mut self.unsettled_wide) {
if let Some(v) = self.wide_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.wide_deps.remove(&key);
}
}
}
}
/// Formulas that read cell `c` of `sheet`.
pub fn dependents(&self, sheet: usize, c: CellRef, 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(c.row, c.row, &mut |k| out.push(*k));
}
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(c.row, c.row, &mut |(c0, c1, k)| {
if *c0 <= c.col && c.col <= *c1 {
out.push(*k);
}
});
}
}
/// 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 {
for c in range.iter() {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
}
} else {
for ((s, c), v) in &self.cell_deps {
if *s == sheet && range.contains(*c) {
out.extend(v.iter().copied());
}
}
}
let (r0, r1) = (range.start.row, range.end.row);
if (range.width() as usize) <= self.col_deps.len() {
for col in range.start.col..=range.end.col {
if let Some(v) = self.col_deps.get(&(sheet, col)) {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
} else {
for ((s, col), v) in &self.col_deps {
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
}
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(r0, r1, &mut |(c0, c1, k)| {
if *c0 <= range.end.col && range.start.col <= *c1 {
out.push(*k);
}
});
}
}
/// Precedent areas of a formula cell (for Trace Precedents).
pub fn precedents_of(&self, k: Key) -> Vec<Area> {
self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default()
}
}
-556
View File
@@ -1,556 +0,0 @@
use super::graph::{HashMap, HashSet, Key};
use super::{Evaluator, Host, Workbook, stack_position};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate
/// whatever order they come in, with bounded stack.
const MAX_NESTED: usize = 64;
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
pub(super) struct PassHost<'a> {
wb: &'a Workbook,
exprs: &'a HashMap<Key, std::sync::Arc<Expr>>,
pub(super) results: HashMap<Key, Value>,
pending: HashSet<Key>,
in_progress: HashSet<Key>,
/// New spills found during the pass: anchor → array.
pub(super) spills: HashMap<Key, std::sync::Arc<Array>>,
/// 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>>,
rng: &'a mut u64,
now: f64,
cycle: bool,
pub(super) cycles: Vec<Key>,
depth: usize,
/// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned
/// and it is calculated first.
suspend: Option<Key>,
/// Formulas whose evaluation was abandoned for another one, to be calculated again after it.
/// They all need it, so reading one of them while it is calculated is a circular reference,
/// as reading a formula in progress is.
waiting: HashSet<Key>,
/// The formulas abandoned since the last suspension.
abandoned: Vec<Key>,
/// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They
/// are calculated along with the suspended one, rather than one per restart (a wide range
/// read at the limit would restart the abandoned evaluations once per cell in it).
also: Vec<Key>,
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
#[cfg(test)]
pub(super) suspensions: usize,
/// The formulas in `cycles`.
cycle_seen: HashSet<Key>,
/// Iterative calculation: a circular reference reads the cell's previous value instead of
/// giving `#CIRC!`.
pub(super) iterate: bool,
/// Values from the previous iteration; cells not in it read their stored value.
prev: HashMap<Key, Value>,
/// Formulas being evaluated, innermost last (kept only when iterating).
stack: Vec<Key>,
/// Formulas found on a circular reference (when iterating).
pub(super) members: HashSet<Key>,
/// Results that read a previous value, directly or through other formulas.
pub(super) tainted: HashSet<Key>,
/// Whether the formula being evaluated has read a previous value.
taint: bool,
/// `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,
}
impl<'a> PassHost<'a> {
pub(super) fn new(wb: &'a Workbook, exprs: &'a HashMap<Key, std::sync::Arc<Expr>>, rng: &'a mut u64, now: f64) -> Self {
PassHost {
wb,
exprs,
results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()),
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng,
now,
cycle: false,
cycles: vec![],
depth: 0,
suspend: None,
waiting: HashSet::default(),
abandoned: Vec::new(),
also: Vec::new(),
#[cfg(test)]
suspensions: 0,
cycle_seen: HashSet::default(),
iterate: false,
prev: HashMap::default(),
stack: Vec::new(),
members: HashSet::default(),
tainted: HashSet::default(),
taint: false,
stack_top: 0,
out_of_stack: false,
}
}
}
/// 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).
fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
let Some(sh) = host.wb.sheet(sheet) else { return false };
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)));
let range = RangeRef::new(anchor, end);
let own = sh.spill_ranges.get(&anchor);
let spilled_over = |c: CellRef| {
!own.is_some_and(|o| o.contains(c))
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
};
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.merges.iter().any(|m| m.intersects(&range))
|| sh.tables.iter().any(|t| t.range.contains(anchor))
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some()))
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
}
/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over
/// `range`: the same area, nothing new in the way, and the same values.
pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool {
let end =
CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
RangeRef::new(anchor, end) == range
&& !sheet.merges.iter().any(|m| m.intersects(&range))
&& !sheet.tables.iter().any(|t| t.range.contains(anchor))
&& range
.iter()
.enumerate()
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
}
impl PassHost<'_> {
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
/// calculated from here, then the suspended ones again (reading the results kept so far).
pub(super) fn calculate(&mut self, k: Key) {
// Formulas to calculate, innermost last, each with the ones abandoned to calculate the
// next.
let mut work: Vec<(Key, Vec<Key>)> = vec![(k, Vec::new())];
while let Some(&(top, _)) = work.last() {
self.compute(top);
match self.suspend.take() {
Some(next) => {
#[cfg(test)]
{
self.suspensions += 1;
}
let held = std::mem::take(&mut self.abandoned);
self.waiting.extend(held.iter().copied());
if let Some(entry) = work.last_mut() {
entry.1 = held;
}
// The other formulas read at the limit are calculated after `next`, before the
// held evaluations restart. Each has nothing held of its own, so the held ones
// are released only when the last of them is done.
let mut seen: HashSet<Key> = HashSet::default();
seen.insert(next);
for s in std::mem::take(&mut self.also) {
if seen.insert(s) {
work.push((s, Vec::new()));
}
}
work.push((next, Vec::new()));
}
None => {
work.pop();
if let Some(entry) = work.last_mut() {
for a in entry.1.drain(..) {
self.waiting.remove(&a);
}
}
}
}
}
}
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
let v = v.clone();
if self.iterate && self.tainted.contains(&k) {
self.taint = true;
}
return v;
}
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
if self.suspend.is_some() {
// Unwinding to `calculate`: the value is thrown away. A formula read at the limit is
// calculated with the suspended one, so the evaluations are not restarted for each.
if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) {
self.also.push(k);
}
return Value::Error(CellError::Calc);
}
if self.in_progress.contains(&k) || self.waiting.contains(&k) {
self.cycle = true;
if self.iterate {
return self.circular_read(k);
}
// Once each, though a suspended evaluation meets it again.
if self.cycle_seen.insert(k) {
self.cycles.push(k);
}
return Value::Error(CellError::Circ);
}
if self.depth >= MAX_NESTED {
self.suspend = Some(k);
return Value::Error(CellError::Calc);
}
if self.depth == 0 {
self.stack_top = stack_position();
}
self.in_progress.insert(k);
self.depth += 1;
let outer_taint = std::mem::replace(&mut self.taint, false);
if self.iterate {
self.stack.push(k);
}
let expr = std::sync::Arc::clone(expr);
let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false);
let v = {
let mut ev = Evaluator::new(self, k.0, k.1);
ev.value(&expr)
};
let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack);
if out_of_stack && self.depth > 1 && self.suspend.is_none() {
// It ran out of the stack left below the formulas that read it: it is calculated
// from the top instead, as a formula needed at `MAX_NESTED` levels deep is.
self.suspend = Some(k);
self.depth -= 1;
self.in_progress.remove(&k);
if self.iterate {
self.stack.pop();
}
self.taint = outer_taint;
return Value::Error(CellError::Calc);
}
if self.suspend.is_some() {
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
self.depth -= 1;
self.in_progress.remove(&k);
self.abandoned.push(k);
if self.iterate {
self.stack.pop();
}
self.taint = outer_taint;
return Value::Error(CellError::Calc);
}
// A legacy array formula fills the range it was entered in.
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
let v = match legacy {
Some(range) => fit_to_range(v, range),
None => v,
};
self.depth -= 1;
self.in_progress.remove(&k);
self.pending.remove(&k);
if self.iterate {
self.stack.pop();
if self.taint {
self.tainted.insert(k);
} else {
self.tainted.remove(&k);
}
}
self.taint |= outer_taint;
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
// of their own, which stay blank.
let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY"));
let v = match v {
Value::Array(a) => {
let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) {
a
} else {
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)
};
// A dynamic-array formula anchors a spill range even while its result is one
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
// returns a scalar never reaches this arm, so it does not become an anchor.
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
}
Value::Empty => Value::Number(0.0),
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);
Value::Error(CellError::Spill)
}
_ => v,
};
self.results.insert(k, v.clone());
v
}
/// A formula's previous value for iterative calculation: from the last iteration, else the
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
fn previous(&self, k: Key) -> Value {
let v = match self.prev.get(&k) {
Some(v) => v.clone(),
None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(),
};
if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v }
}
/// Reading `k` while it is being evaluated, with iterative calculation on: its previous value.
/// The formulas around the loop are the ones whose changes decide when iteration stops.
fn circular_read(&mut self, k: Key) -> Value {
self.taint = true;
match self.stack.iter().rposition(|s| *s == k) {
Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()),
// Too deep to follow: the formula itself is watched.
None => {
self.members.insert(k);
}
}
self.previous(k)
}
/// Iterative calculation, after a first pass over `order` that met circular references. The
/// formulas that read a previous value (the loops and what depends on them) are evaluated
/// again in the same order, each pass starting from the last one's values and reading what it
/// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or
/// more, or after `max_iterations` passes in all. Returns the number of passes.
pub(super) fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 {
let again: Vec<Key> = order.iter().copied().filter(|k| self.tainted.contains(k)).collect();
let set: HashSet<Key> = again.iter().copied().collect();
// Bounds the work whatever Maximum Iterations says.
let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1);
let mut passes = 1u32;
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));
for k in &again {
self.calculate(*k);
}
passes += 1;
}
passes
}
/// Whether every formula on a loop changed by less than `max_change` in the last pass (text,
/// logical and error values: not at all).
fn converged(&self, max_change: f64) -> bool {
self.members.iter().all(|k| {
let Some(new) = self.results.get(k) else { return true };
match (new, &self.previous(*k)) {
(Value::Number(a), Value::Number(b)) => {
let d = (a - b).abs();
d < max_change || d == 0.0
}
(new, old) => new == old,
}
})
}
}
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
/// Excel does: a single row or column repeats across the range, a single value fills it, cells
/// past the result show `#N/A`, and a result larger than the range is cut off.
fn fit_to_range(v: Value, range: RangeRef) -> Value {
if range.count() > crate::eval::MAX_CELLS {
return Value::Error(CellError::Num);
}
let (h, w) = (range.height() as usize, range.width() as usize);
let Value::Array(a) = v else {
return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) };
};
if h * w == 1 {
return a.data.first().cloned().unwrap_or_default();
}
let mut data = Vec::with_capacity(h * w);
for r in 0..h {
for c in 0..w {
let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c });
data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA)));
}
}
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
fn stack_top(&self) -> Option<usize> {
(self.depth > 1).then_some(self.stack_top)
}
fn stack_exhausted(&mut self) {
self.out_of_stack = true;
}
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
let k = (sheet, anchor);
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied();
}
// Recalculated in this pass: its new array, unless something blocks it (#SPILL!).
self.compute(k);
let arr = self.spills.get(&k)?;
let end = CellRef::new(
anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)),
anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
let range = RangeRef::new(anchor, end);
let sh = self.wb.sheet(sheet)?;
let blocked = end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty()))
|| sh.merges.iter().any(|m| m.intersects(&range));
(!blocked).then_some(range)
}
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
let k = (sheet, c);
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();
}
}
}
self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default()
}
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
let (h, w) = (range.height() as usize, range.width() as usize);
let mut out = vec![Value::Empty; h * w];
let Some(sh) = self.wb.sheet(sheet) else { return out };
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
// `compute`, constants are copied straight from the store.
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
for (c, cell) in sh.cells.iter_range(range) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if cell.formula.is_some() {
formulas.push((i, c));
} else if let Some(slot) = out.get_mut(i) {
*slot = cell.value.clone();
}
}
for (c, v) in sh.spill.range(range.start..=range.end) {
if range.contains(*c) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if let Some(slot) = out.get_mut(i)
&& slot.is_empty()
{
*slot = v.clone();
}
}
}
for (i, c) in formulas {
let v = self.cell_value(sheet, c);
if let Some(slot) = out.get_mut(i) {
*slot = v;
}
}
if !self.spills.is_empty() {
let new: Vec<(usize, Value)> = self
.spills
.iter()
.filter(|(a, _)| a.0 == sheet)
.flat_map(|(a, arr)| {
let mut v = Vec::new();
for r in 0..arr.rows {
for cc in 0..arr.cols {
let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32));
if c != a.1 && range.contains(c) {
v.push((
(c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize,
arr.get(r, cc).cloned().unwrap_or_default(),
));
}
}
}
v
})
.collect();
// 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)
})
{
self.taint = true;
}
for (i, v) in new {
if let Some(slot) = out.get_mut(i) {
*slot = v;
}
}
}
out
}
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
let wb = self.wb;
let Some(sh) = wb.sheet(sheet) else { return false };
// Formula cells first, in row-major order like `range_values`: reading one calculates it
// and the pass remembers the value, so the walk below gets the same value again without
// this method holding any.
for (c, cell) in sh.cells.iter_range(range) {
if cell.formula.is_some() {
let _ = self.cell_value(sheet, c);
}
}
// Spilled values sit between the stored cells (and the formulas just calculated may have
// produced some): leave such blocks to `range_values`.
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
return false;
}
for (c, cell) in sh.cells.iter_range(range) {
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
if !go {
break;
}
}
true
}
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()))
}
fn now_serial(&self) -> f64 {
self.now
}
fn random(&mut self) -> f64 {
// xorshift64*
let mut x = *self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
*self.rng = x;
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
}
-138
View File
@@ -384,32 +384,6 @@ fn circular_references_without_iterative_calculation() {
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
/// The list of circular references keeps the loops that a recalculation of other cells does not
/// reach, and drops a loop once a constant is typed over one of its cells.
#[test]
fn circular_references_stay_listed_until_the_loop_is_removed() {
let mut t = T::new();
t.set("A1", "=A1+1");
t.set("B1", "=B2");
t.set("B2", "=B1");
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
t.set("D4", "7");
t.set("D5", "=D4*2");
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
t.set("A1", "5");
assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
t.set("B2", "3");
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
// In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it.
t.wb.calc.mode = gridcraft_model::CalcMode::Manual;
t.set("C1", "=C1+1");
assert_eq!(t.calc.circular, vec![(0, c("C1"))]);
t.set("C1", "1");
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
}
#[test]
fn date_text_coerces_in_the_workbook_date_system() {
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
@@ -596,24 +570,6 @@ fn lambda_helpers_check_shapes_and_parameters() {
assert_eq!(t.num("B7"), 2.0);
}
#[test]
fn lambda_calls_spend_one_work_budget_per_cell() {
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
let mut t = T::new();
t.set(
"A1",
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
);
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
assert_eq!(t.num("A1"), 45.0);
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
@@ -1051,100 +1007,6 @@ fn lambda_argument_count() {
assert_eq!(t.get("A9"), Value::Bool(true));
}
#[test]
fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
// 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
let handle = std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(|| {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName {
name: "LOOP".into(),
scope: None,
formula: "LAMBDA(n,LOOP(n))".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
t.set("A1", endless);
t.set("A2", "=LOOP(1)");
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
})
.expect("spawn");
let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
assert_eq!(endless, Value::Error(CellError::Num));
assert_eq!(endless_name, Value::Error(CellError::Num));
assert_eq!(through_map, Value::Error(CellError::Num));
assert_eq!(evaluated, Value::Error(CellError::Num));
assert_eq!(countdown, Value::Number(131.0));
}
#[test]
fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
let run = |stack: usize| {
std::thread::Builder::new()
.stack_size(stack)
.spawn(|| {
let mut wb = Workbook::new();
wb.names.push(gridcraft_model::DefinedName {
name: "CNT".into(),
scope: None,
formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
comment: String::new(),
hidden: false,
});
// F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
[tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
})
.expect("spawn")
.join()
.expect("recursion must not overflow the stack")
};
let small = run(1 << 20);
assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
assert_eq!(run(64 << 20), small);
}
#[test]
fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() {
let run = |stack: usize| {
std::thread::Builder::new()
.stack_size(stack)
.spawn(|| {
// Row-major order calculates A1 first, so each cell below is calculated on demand
// at the deepest call of the one above it.
let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))");
let mut wb = Workbook::new();
let sheet = wb.sheet_mut(0).expect("sheet");
for (at, f) in [
("A1", countdown("A2")),
("A2", countdown("A3")),
("A3", countdown("0")),
("B1", countdown("B2")),
("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()),
] {
sheet.cells.set(c(at), Cell::formula(Formula::new(&f)));
}
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default())
})
.expect("spawn")
.join()
.expect("recursion must not overflow the stack")
};
let small = run(1 << 20);
let num = Value::Error(CellError::Num);
assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]);
assert_eq!(run(64 << 20), small);
}
#[test]
fn names_and_sheets() {
let mut t = T::new();
+1636 -17
View File
File diff suppressed because it is too large Load Diff
-452
View File
@@ -1,452 +0,0 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, plot_kind};
use super::lines::{decimate, 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,
};
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Fam {
Col,
ColStack,
Col100,
Line,
LineStack,
Area,
AreaStack,
Area100,
}
/// Stack accumulator: (axis, family) key with positive and negative running sums.
type Stack = ((bool, Fam), Vec<f64>, Vec<f64>);
fn family(k: ChartKind) -> Fam {
use ChartKind::*;
match k {
ColumnStacked | BarStacked => Fam::ColStack,
ColumnStacked100 | BarStacked100 => Fam::Col100,
Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line,
LineStacked => Fam::LineStack,
Area => Fam::Area,
AreaStacked => Fam::AreaStack,
AreaStacked100 => Fam::Area100,
_ => Fam::Col,
}
}
/// Families stacked to 100%: each category's values are shares of its total.
fn is_100(f: Fam) -> bool {
matches!(f, Fam::Col100 | Fam::Area100)
}
fn is_col(f: Fam) -> bool {
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
}
fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
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();
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
if is_100(fam) {
let key = (sec.get(i).copied().unwrap_or(false), fam);
let p = match totals.iter().position(|(k, _)| *k == key) {
Some(p) => p,
None => {
totals.push((key, vec![0.0; ncat]));
totals.len() - 1
}
};
for (j, v) in s.values.iter().take(ncat).enumerate() {
if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) {
*t += v.abs();
}
}
}
}
// A value's share of its category's total, for the 100% families.
let share = |key: (bool, Fam), j: usize, v: f64| {
let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0);
if t > 0.0 { v / t } else { 0.0 }
};
// Stack accumulators keyed by (axis, family): positive and negative running sums.
let mut stacks: Vec<Stack> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let a = sec.get(i).copied().unwrap_or(false);
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);
let key = (a, fam);
let si = if stacked {
match stacks.iter().position(|(k, _, _)| *k == key) {
Some(p) => Some(p),
None => {
stacks.push((key, vec![0.0; ncat], vec![0.0; ncat]));
Some(stacks.len() - 1)
}
}
} else {
None
};
let mut row = Vec::with_capacity(ncat);
for j in 0..ncat {
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| {
let v = if fam == Fam::Col100 { share(key, j, v) } else { v };
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
let base = *acc;
*acc += v;
Some((base, *acc))
}),
(Some((_, pos, _)), _) => {
let v = v.unwrap_or(0.0);
let v = if fam == Fam::Area100 { share(key, j, v) } else { v };
pos.get_mut(j).map(|acc| {
let base = *acc;
*acc = clean(*acc + v);
(base, *acc)
})
}
(None, _) => v.map(|v| (0.0, v)),
};
row.push(span);
}
spans.push(row);
}
spans
}
fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option<(f64, f64)>>]) -> Vec<(Scale, Fmt)> {
// Axis scales.
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
for axis in [false, true] {
let members: Vec<usize> = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect();
if members.is_empty() {
continue;
}
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
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] })
}));
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%"))
} 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))
};
axes.push((scale, fmt));
if !axis && sec.iter().all(|s| !s) {
break;
}
}
axes
}
fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, f32) {
// Column slots: each clustered series gets one, each stack group shares one.
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
let mut slot_of = vec![0usize; fams.len()];
for (i, f) in fams.iter().enumerate() {
if !is_col(*f) {
continue;
}
let a = sec.get(i).copied().unwrap_or(false);
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
let k = match slot_keys.iter().position(|k| *k == key) {
Some(p) => p,
None => {
slot_keys.push(key);
slot_keys.len() - 1
}
};
if let Some(s) = slot_of.get_mut(i) {
*s = k;
}
}
(slot_of, slot_keys.len().max(1) as f32)
}
pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).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();
let spans = series_spans(data, &fams, &sec, ncat);
let axes = axis_scales(data, &fams, &sec, &spans);
let Some((s1, f1)) = axes.first() else { return };
let y1 = ValAxis { scale: *s1, fmt: f1 };
let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f });
// A forward forecast on a category axis adds empty category slots after the data.
let fwd = data
.series
.iter()
.enumerate()
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
.map(|(_, s)| max_forward(s))
.fold(0.0, f64::max);
let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat));
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
// Outer levels padded to the slots (forecast slots stay in the last group).
let outer: Vec<Vec<String>> =
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } };
let cat_len = if horizontal { plot.h } else { plot.w };
let cw = cat_len / ncat_slots as f32;
let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw };
let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h };
let (slot_of, nslots) = column_slots(&fams, &sec);
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
let mut area_labels = Vec::new();
// Areas first, then columns, then lines on top.
for pass in 0..3 {
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let want = match pass {
0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100),
1 => is_col(fam),
_ => is_line(fam),
};
if !want {
continue;
}
let sc = scale_of(i);
let fmt = Fmt::new(&s.number_format);
let row = match spans.get(i) {
Some(r) => r,
None => continue,
};
match pass {
0 => {
let mut top = Vec::with_capacity(ncat * 2);
let mut base = Vec::with_capacity(ncat);
for (j, sp) in row.iter().enumerate() {
let (b, t) = sp.unwrap_or((0.0, 0.0));
let c = center(j);
top.push([c, vpos(t, &sc)]);
base.push([c, vpos(b, &sc)]);
// Source values (not stack totals), just above the top edge like the line labels.
if labels_on
&& sp.is_some()
&& let Some(v) = s.values.get(j).copied().flatten()
{
area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None)));
}
}
if top.len() == 1 {
// A single point: give it some width.
if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) {
top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]];
base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]];
}
}
let top = decimate(top, MAX_LINE_PTS * 2);
let base = decimate(base, MAX_LINE_PTS * 2);
let mut poly = top;
poly.extend(base.into_iter().rev());
if poly.len() >= 3 {
ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None });
}
}
1 => {
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
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);
}
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
let t = fmt.format(v, None);
let stacked = fam != Fam::Col;
let mid = (pb + pt) / 2.0;
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 0.5) * bw;
if stacked {
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
} else if v >= 0.0 {
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
} else {
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
}
} else {
let x = c - nslots * bw / 2.0 + (k + 0.5) * bw;
if stacked {
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
} else if v >= 0.0 {
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
} else {
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top));
}
}
}
}
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot));
let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);
if k == ChartKind::LineMarkers && cw >= 3.0 {
draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);
}
if labels_on {
for (j, p) in pts.iter().enumerate() {
if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) {
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
}
}
}
for (x, y, t) in area_labels.drain(..) {
ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
// Trendlines on top: x is the category number 1..n.
let mut trend_labels = Vec::new();
for (i, s) in data.series.iter().enumerate() {
if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) {
continue;
}
let sc = scale_of(i);
let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect();
let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min));
let px = |(fx, fy): (f64, f64)| {
if horizontal {
[plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h]
} else {
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
}
};
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
}
draw_trend_labels(ctx, plot, &trend_labels);
}
// ---------------------------------------------------------------- scatter & bubble
pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let pts_of = |s: &SeriesData| -> Vec<Option<(f64, f64)>> {
s.values
.iter()
.enumerate()
.map(|(j, v)| {
let x = match &s.x {
Some(xs) => xs.get(j).copied().flatten(),
None => Some((j + 1) as f64),
};
match (x, v) {
(Some(x), Some(y)) => Some((x, *y)),
_ => None,
}
})
.collect()
};
let all: Vec<Vec<Option<(f64, f64)>>> = data.series.iter().map(pts_of).collect();
let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return };
// Forecasts extend the x axis to the trendline's ends.
for (s, pts) in data.series.iter().zip(&all) {
if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) {
xlo = xlo.min(lo - max_backward(s));
xhi = xhi.max(hi + max_forward(s));
}
}
let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return };
let xs = nice_scale(xlo, xhi, false);
let ys = nice_scale(ylo, yhi, false);
let fx = Fmt::new("General");
let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h];
let bubble = chart.kind == ChartKind::Bubble;
let rmax = plot.w.min(plot.h) * 0.12;
for (s, pts) in data.series.iter().zip(&all) {
let px: Vec<Option<[f32; 2]>> = pts.iter().map(|p| p.map(map)).collect();
if bubble {
let sizes = s.sizes.as_deref().unwrap_or(&[]);
let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b));
let mut bubbles: Vec<([f32; 2], f64)> = px
.iter()
.enumerate()
.filter_map(|(j, p)| {
let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() };
match (p, sz) {
(Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)),
_ => None,
}
})
.take(MAX_MARKERS)
.collect();
bubbles.sort_by(|a, b| b.1.total_cmp(&a.1));
let smax = if sizes.is_empty() { 1.0 } else { smax };
let fill = [s.color[0], s.color[1], s.color[2], 0xC0];
for (p, sz) in bubbles {
let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0);
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) });
}
} else {
let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines;
if lines {
draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot));
}
if !lines || px.len() <= 500 {
draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color);
}
}
if chart.data_labels && pts.len() <= MAX_LABELS {
for (p, raw) in px.iter().zip(pts) {
if let (Some(p), Some((_, y))) = (p, raw) {
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
}
let mut trend_labels = Vec::new();
for (s, pts) in data.series.iter().zip(&all) {
if s.trendlines.is_empty() {
continue;
}
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max));
}
draw_trend_labels(ctx, plot, &trend_labels);
}
-245
View File
@@ -1,245 +0,0 @@
use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
#[derive(Clone, Copy, Debug)]
pub(super) struct Bx {
pub(super) x: f32,
pub(super) y: f32,
pub(super) w: f32,
pub(super) h: f32,
}
impl Bx {
pub(super) fn right(&self) -> f32 {
self.x + self.w
}
pub(super) fn bottom(&self) -> f32 {
self.y + self.h
}
pub(super) fn ok(&self) -> bool {
self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0
}
pub(super) fn cx(&self) -> f32 {
self.x + self.w / 2.0
}
pub(super) fn cy(&self) -> f32 {
self.y + self.h / 2.0
}
}
/// Text colour readable on `fill`.
pub(super) fn on(fill: Rgba) -> Rgba {
let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32;
if l < 150.0 { WHITE } else { DARK_TEXT }
}
pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option<String> {
match &chart.title {
Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()),
None => None,
}
}
pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {
let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;
if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
area.h -= lh;
}
if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h);
if !t.is_empty() {
ctx.out.push(Prim::Text {
x: area.x,
y: area.cy(),
text: t,
size: AXIS_TITLE_SIZE,
color: TEXT,
bold: false,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -PI / 2.0,
});
}
area.x += lh;
area.w -= lh;
}
area
}
// ---------------------------------------------------------------- axes frame
pub(super) enum CatAxis<'a> {
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
/// one entry per category, non-empty where a group starts).
Labels(&'a [String], &'a [Vec<String>]),
Numeric(Scale, &'a Fmt),
}
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
/// non-empty entry (and at the first category).
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
starts.push(n);
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
pub(super) struct ValAxis<'a> {
pub(super) scale: Scale,
pub(super) fmt: &'a Fmt,
}
impl ValAxis<'_> {
fn labels(&self) -> Vec<(f64, String)> {
self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect()
}
}
/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle.
pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx {
let ls = LABEL_SIZE;
let lh = ls * 1.4;
let ylabels = y.labels();
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
if !horizontal {
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
let y2labels = y2.map(ValAxis::labels);
let right = match (&y2labels, &cat) {
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
_ => 4.0,
};
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) => 0.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
if !plot.ok() {
return plot;
}
for (v, t) in &ylabels {
let yy = plot.bottom() - y.scale.frac(*v) * plot.h;
if grid {
ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);
}
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
if let (Some(a), Some(l)) = (y2, &y2labels) {
for (v, t) in l {
let yy = plot.bottom() - a.scale.frac(*v) * plot.h;
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Labels(l, outer) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in l.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
// Outer levels, one row each below the labels: a group's name centred under its
// categories, and a separator at each group boundary down through the rows.
for (i, level) in outer.iter().enumerate() {
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
for (first, end) in level_groups(level, n) {
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if first > 0 {
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
CatAxis::Numeric(s, f) => {
let ticks = s.ticks();
let labels: Vec<String> = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect();
let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let slot = plot.w / ticks.len().max(1) as f32;
let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (v, t) in ticks.iter().zip(&labels).step_by(k) {
let xx = plot.x + s.frac(*v) * plot.w;
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if s.min <= 0.0 && s.max >= 0.0 {
let xx = plot.x + s.frac(0.0) * plot.w;
ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75);
}
}
}
plot
} else {
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
CatAxis::Labels(l, o) => (l.to_vec(), o),
CatAxis::Numeric(..) => (vec![], &[]),
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
// Outer levels stand in columns left of the labels, each as wide as its longest name.
let level_w: Vec<f32> =
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
let levels_w: f32 = level_w.iter().sum();
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
let vw = maxw(ctx, &ylabels);
let slot = plot.w / ylabels.len().max(1) as f32;
let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (i, (v, t)) in ylabels.iter().enumerate() {
let xx = plot.x + y.scale.frac(*v) * plot.w;
if grid {
ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);
}
if i % k == 0 {
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let x0 = plot.x + y.scale.frac(zero) * plot.w;
ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75);
let n = labels.len().max(1);
let ch = plot.h / n as f32;
let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in labels.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
// Outer levels: a group's name centred beside its categories, separators at the
// boundaries running right to the axis.
let mut x1 = plot.x - lw;
for (level, w) in outer.iter().zip(&level_w) {
let x0 = x1 - w;
for (first, end) in level_groups(level, n) {
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, w - 4.0);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle));
}
if first > 0 {
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
}
}
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
x1 = x0;
}
plot
}
}
pub(super) fn value_range(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
it.fold(None, |acc, v| match acc {
None => Some((v, v)),
Some((a, b)) => Some((a.min(v), b.max(v))),
})
}
-508
View File
@@ -1,508 +0,0 @@
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,
};
// ---------------------------------------------------------------- histogram
pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().flatten().copied().collect();
let Some((lo, hi)) = value_range(vals.iter().copied()) else { return };
let n = vals.len();
let span = hi - lo;
let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100);
if !(span.is_finite() && span > 0.0) {
bins = 1;
}
let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 };
// Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value
// equal to an edge belongs to the bin that ends there.
let edges: Vec<f64> = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect();
let mut counts = vec![0usize; bins];
for v in &vals {
let b = edges.partition_point(|e| e < v).min(bins - 1);
if let Some(c) = counts.get_mut(b) {
*c += 1;
}
}
let f = Fmt::new(&s.number_format);
let labels: Vec<String> = (0..bins)
.map(|b| {
let a = lo + b as f64 * width;
let e = edges.get(b).copied().unwrap_or(a);
let open = if b == 0 { '[' } else { '(' };
format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0)))
})
.collect();
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
let ys = nice_scale(0.0, maxc, true);
let fc = Fmt::new("General");
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let cw = plot.w / bins as f32;
for (b, c) in counts.iter().enumerate() {
let top = plot.bottom() - ys.frac(*c as f64) * plot.h;
let x = plot.x + b as f32 * cw;
ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75)));
if chart.data_labels {
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
// ---------------------------------------------------------------- waterfall
pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS);
if ncat == 0 {
return;
}
let mut labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let mut vals: Vec<f64> = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect();
let mut is_total = vec![false; ncat];
let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total"));
if let Some(t) = is_total.last_mut() {
*t = last_total;
}
if !last_total {
labels.push("Total".into());
vals.push(0.0);
is_total.push(true);
ncat += 1;
}
let mut bars = Vec::with_capacity(ncat);
let mut cum = 0.0f64;
for (j, v) in vals.iter().enumerate() {
if is_total.get(j).copied().unwrap_or(false) {
let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum };
bars.push((0.0, total, 0u8));
cum = total;
} else {
let top = clean(cum + v);
bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 }));
cum = top;
}
}
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
let ys = nice_scale(lo, hi, true);
let f = Fmt::new(&s.number_format);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let cw = plot.w / ncat as f32;
let bw = cw / 1.5;
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for (j, (b, t, kind)) in bars.iter().enumerate() {
let color = match kind {
1 => UP,
2 => DOWN,
_ => s.color,
};
let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0;
let (yb, yt) = (vy(*b), vy(*t));
ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None);
if j + 1 < bars.len() && ncat <= 500 {
ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true });
}
if chart.data_labels && ncat <= MAX_LABELS {
let v = if *kind == 0 { *t } else { t - b };
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
// ---------------------------------------------------------------- funnel
pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let n = data.categories.len().max(s.values.len()).min(MAX_CATS);
if n == 0 {
return;
}
let vals: Vec<f64> = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect();
let max = vals.iter().copied().fold(0.0f64, f64::max);
if max.is_nan() || max <= 0.0 {
return;
}
let labels: Vec<String> = (0..n).map(|j| cat_label(data, j)).collect();
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35);
let plot = Bx { x: area.x + lw, w: area.w - lw, ..area };
if !plot.ok() {
return;
}
let slot = plot.h / n as f32;
let bh = slot * 0.85;
let f = Fmt::new(&s.number_format);
let show = slot >= LABEL_SIZE * 0.9;
for (j, v) in vals.iter().enumerate() {
let bw = (*v / max) as f32 * plot.w;
let x = plot.x + (plot.w - bw) / 2.0;
let y = plot.y + j as f32 * slot + (slot - bh) / 2.0;
if bw > 0.0 {
ctx.rect(x, y, bw, bh, s.color, None);
}
if show {
let yc = y + bh / 2.0;
if chart.data_labels {
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, (HAlign::Center, VAlign::Middle));
}
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
}
}
}
// ---------------------------------------------------------------- pie, doughnut, sunburst
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();
let total: f64 = vals.iter().sum();
if !(total.is_finite() && total > 0.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 ri = r * inner;
let f = Fmt::new(&s.number_format);
let mut a = 0.0f32;
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)) });
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;
let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 };
let room = sweep * rr;
if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= 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();
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)> {
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(&with, side) <= worst(&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
}
pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let mut items: Vec<(usize, f64)> =
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) {
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));
}
}
}
}
// ---------------------------------------------------------------- radar
pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000);
if ncat == 0 {
return;
}
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2);
let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0);
if r < 5.0 {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0));
let sc = nice_scale(lo, hi, true);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let ang = |j: usize| j as f32 / ncat as f32 * TAU;
let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()];
let ticks = sc.ticks();
let spacing = r / ticks.len().saturating_sub(1).max(1) as f32;
let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1);
for (ti, t) in ticks.into_iter().enumerate() {
let rr = sc.frac(t) * r;
if rr > 0.5 && chart.gridlines {
let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect();
if ncat < 3 {
ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect();
} else if let Some(p) = ring.first().copied() {
ring.push(p);
}
ctx.line(ring, GRID, 0.75);
}
if ti % every == 0 {
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, (HAlign::Right, VAlign::Middle));
}
}
let step = ncat.div_ceil(72).max(1);
for j in (0..ncat).step_by(step) {
let a = ang(j);
ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75);
let p = pt(a, r + 6.0);
let (sa, ca) = (a.sin(), a.cos());
let align = if sa > 0.1 {
HAlign::Left
} else if sa < -0.1 {
HAlign::Right
} else {
HAlign::Center
};
let valign = if ca > 0.1 {
VAlign::Bottom
} else if ca < -0.1 {
VAlign::Top
} else {
VAlign::Middle
};
let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0));
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, (align, valign));
}
for s in &data.series {
let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect();
if let Some(p) = pts.first().copied() {
pts.push(p);
}
ctx.line(pts, s.color, 2.0);
}
}
// ---------------------------------------------------------------- box & whisker
fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
// Clamp exclusive ranks for short samples so one or two points remain drawable.
let pos = match method {
QuartileMethod::Inclusive => q * (n - 1) as f64,
QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64),
};
let i = pos.floor() as usize;
let frac = pos - i as f64;
let a = sorted.get(i).copied().unwrap_or(0.0);
let b = sorted.get(i + 1).copied().unwrap_or(a);
a + (b - a) * frac
}
pub(super) fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let labels: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let n = data.series.len().max(1);
let cw = plot.w / n as f32;
let bw = (cw * 0.4).min(80.0);
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for (i, s) in data.series.iter().enumerate() {
let mut v: Vec<f64> = s.values.iter().flatten().copied().collect();
if v.is_empty() {
continue;
}
v.sort_by(f64::total_cmp);
let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default();
let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method));
let iqr = q3 - q1;
let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1);
let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3);
let mean = v.iter().sum::<f64>() / v.len() as f64;
let c = plot.x + (i as f32 + 0.5) * cw;
ctx.vline(c, vy(whi), vy(q3), s.color, 1.25);
ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25);
ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25);
ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25);
let (yt, yb) = (vy(q3), vy(q1));
ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None);
ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5);
let (mx, my, d) = (c, vy(mean), 3.0);
ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25);
ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25);
for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) {
ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) });
}
}
}
// ---------------------------------------------------------------- stock
pub(super) fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let ser = |k: usize| data.series.get(k);
let (open, high, low, close) = match data.series.len() {
0 => return,
1 => (None, None, None, ser(0)),
2 => (None, ser(0), ser(1), None),
3 => (None, ser(0), ser(1), ser(2)),
_ => (ser(0), ser(1), ser(2), ser(3)),
};
let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten());
let cw = plot.w / ncat as f32;
let tick = (cw * 0.25).max(1.0);
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for j in 0..ncat {
let c = plot.x + (j as f32 + 0.5) * cw;
if let (Some(h), Some(l)) = (get(high, j), get(low, j)) {
ctx.vline(c, vy(h), vy(l), STOCK, 1.25);
}
match (get(open, j), get(close, j)) {
(Some(o), Some(cl)) => {
let (yo, yc) = (vy(o), vy(cl));
let bw = (cw * 0.5).max(1.0);
let fill = if cl >= o { WHITE } else { STOCK };
ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0)));
}
(None, Some(cl)) => {
if high.is_some() {
ctx.hline(c, c + tick, vy(cl), STOCK, 1.25);
} else {
ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None });
}
}
_ => {}
}
}
}
#[cfg(test)]
mod quartile_tests {
use super::*;
#[test]
fn box_whisker_default_quartiles_are_exclusive() {
let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75);
assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5);
assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25);
}
#[test]
fn quartile_methods_and_short_samples() {
for (values, exclusive, inclusive) in [
(vec![1., 2., 3.], (1., 3.), (1.5, 2.5)),
(vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)),
(vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)),
(vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)),
(vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)),
(vec![1., 2.], (1., 2.), (1.25, 1.75)),
(vec![1.], (1., 1.), (1., 1.)),
(vec![], (0., 0.), (0., 0.)),
] {
for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] {
assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected);
}
}
}
}
-165
View File
@@ -1,165 +0,0 @@
use super::frame::Bx;
use super::trend::supports_trendline;
use super::{
Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP,
VAlign, auto_name, series_color,
};
use gridcraft_model::LegendPos;
/// How a legend entry's swatch is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Swatch {
Box,
Line,
LineMarker,
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 }
}
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba {
match (j, data.series.first()) {
(0, Some(s)) => s.color,
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
}
}
fn first_len(data: &ChartData) -> usize {
data.series.first().map(|s| s.values.len()).unwrap_or(0)
}
pub(super) fn cat_label(data: &ChartData, j: usize) -> String {
data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string())
}
/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden.
pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> {
if chart.legend == LegendPos::None {
return vec![];
}
match chart.kind {
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()
}
ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![],
ChartKind::Waterfall => {
if data.series.is_empty() {
return vec![];
}
let total = data.series.first().map(|s| s.color).unwrap_or(TEXT);
vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)]
}
_ => {
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) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
_ => Swatch::Box,
};
(s.name.clone(), s.color, sw)
})
.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 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));
}
}
out
}
}
}
// ---------------------------------------------------------------- legend
fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
match sw {
Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
Swatch::LineMarker => {
ctx.hline(x, x + 2.0 * s, yc, color, 2.0);
ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None });
}
Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }),
}
}
pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {
let size = LABEL_SIZE;
let row_h = size * 1.6;
let s = size * 0.8;
let gap = 4.0;
let spacing = size * 1.3;
let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);
match pos {
LegendPos::None => area,
LegendPos::Top | LegendPos::Bottom => {
let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);
let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];
let mut cur = 0.0f32;
for (i, e) in entries.iter().enumerate() {
let iw = item_w(ctx, e).min(area.w);
let need = if cur > 0.0 { spacing + iw } else { iw };
if cur > 0.0 && cur + need > area.w {
if rows.len() >= max_rows {
break;
}
rows.push(vec![]);
cur = 0.0;
}
cur += if cur > 0.0 { spacing + iw } else { iw };
if let Some(r) = rows.last_mut() {
r.push((i, iw));
}
}
let lh = rows.len() as f32 * row_h;
let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };
for (ri, row) in rows.iter().enumerate() {
let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;
let mut x = area.x + (area.w - total).max(0.0) / 2.0;
let yc = y0 + ri as f32 * row_h + row_h / 2.0;
for &(i, iw) in row {
if let Some(e) = entries.get(i) {
draw_swatch(ctx, e.2, x, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
x += iw + spacing;
}
}
let used = lh + 4.0;
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);
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;
let y0 = area.y + (area.h - lh).max(0.0) / 2.0;
let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };
for (i, e) in entries.iter().take(n).enumerate() {
let yc = y0 + i as f32 * row_h + row_h / 2.0;
draw_swatch(ctx, e.2, x0, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
let used = colw + 8.0;
if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
}
}
}
-158
View File
@@ -1,158 +0,0 @@
use super::frame::Bx;
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
use std::collections::HashSet;
// ---------------------------------------------------------------- helpers
/// Reduces a long polyline to first/min/max/last per pixel column.
pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
if pts.len() <= max {
return pts;
}
let mut out = Vec::with_capacity(max * 2);
let mut i = 0;
while let Some(p) = pts.get(i) {
let col = p[0].floor();
let (start, mut mn, mut mx) = (i, i, i);
let mut k = i + 1;
while let Some(q) = pts.get(k) {
if q[0].floor() != col {
break;
}
if pts.get(mn).is_some_and(|m| q[1] < m[1]) {
mn = k;
}
if pts.get(mx).is_some_and(|m| q[1] > m[1]) {
mx = k;
}
k += 1;
}
let mut idx = [start, mn, mx, k - 1];
idx.sort_unstable();
let mut last = usize::MAX;
for ix in idx {
if ix != last
&& let Some(q) = pts.get(ix)
{
out.push(*q);
last = ix;
}
}
i = k;
}
if out.len() > max * 4 {
let step = out.len() / (max * 2) + 1;
let last = out.last().copied();
let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect();
if let Some(l) = last
&& s.last() != Some(&l)
{
s.push(l);
}
return s;
}
out
}
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
let mut run: Vec<[f32; 2]> = Vec::new();
for p in pts.iter().chain(std::iter::once(&None)) {
match p {
Some(p) => run.push(*p),
None => {
if run.len() >= 2 {
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
let r = match smooth {
Some(plot) => smooth_run(r, plot),
None => r,
};
ctx.line(r, color, width);
} else {
run.clear();
}
}
}
}
}
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
/// duplicated so the curve passes through the first and last points, and the curve is clamped
/// to the `plot` box (a spline overshoots its data near sharp turns).
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
let n = pts.len();
if n < 3 {
return pts;
}
let cap = MAX_LINE_PTS * 2;
let steps = (cap / n).clamp(1, 100);
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
let at = |i: isize| -> [f32; 2] {
let j = i.clamp(0, n as isize - 1) as usize;
pts.get(j).copied().unwrap_or([0.0, 0.0])
};
let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right()));
let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom()));
let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)];
for i in 0..n.saturating_sub(1) {
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
for s in 0..steps {
let t = s as f32 / steps as f32;
out.push(clip(catmull(p0, p1, p2, p3, t)));
}
}
out.push(clip(at(n as isize - 1)));
out
}
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
let t2 = t * t;
let t3 = t2 * t;
let mut o = [0.0f32; 2];
for k in 0..2 {
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
}
o
}
pub(super) fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
let mut seen = HashSet::new();
for p in pts {
if seen.len() >= MAX_MARKERS {
break;
}
if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) {
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) });
}
}
}
#[cfg(test)]
mod smooth_tests {
use super::*;
#[test]
fn long_runs_are_smoothed_uniformly_within_the_cap() {
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
let out = smooth_run(pts, plot);
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
assert_eq!(out.len(), 1499 * 2 + 1);
assert_eq!(out.last().copied(), Some([2998.0, 80.0]));
}
#[test]
fn spline_overshoot_is_clamped_to_the_plot() {
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
// A step right at the plot edges: Catmull-Rom overshoots past both.
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
let out = smooth_run(pts, plot);
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
}
-170
View File
@@ -1,170 +0,0 @@
use super::frame::{Bx, value_range};
use super::lines::decimate;
use super::{
ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign,
fit_trendline, moving_average,
};
// ---------------------------------------------------------------- trendlines
/// Samples along a regression trendline.
const TREND_STEPS: usize = 200;
/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column,
/// bar, line, area, scatter and bubble charts).
pub fn supports_trendline(k: ChartKind) -> bool {
use ChartKind::*;
matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble)
}
/// A forecast distance as drawn: finite, non-negative and bounded.
fn forecast(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
}
/// Most empty category slots a forward forecast may add after `ncat` categories: four times the
/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every
/// column to nothing.
pub(super) fn max_forward_slots(ncat: usize) -> f64 {
(ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64
}
/// Longest forward forecast among `s`'s regression trendlines.
pub(super) fn max_forward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
}
/// Longest backward forecast among `s`'s regression trendlines.
pub(super) fn max_backward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
}
type ClippedSegment = ((f64, f64), (f64, f64), bool, bool);
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
/// end were cut.
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<ClippedSegment> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let r = q / p;
if p < 0.0 {
if r > t1 {
return None;
}
t0 = t0.max(r);
} else {
if r < t0 {
return None;
}
t1 = t1.min(r);
}
}
}
Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0))
}
/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit
/// square), returning the visible runs.
fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec<Vec<(f64, f64)>> {
const LIM: f64 = 1e9;
let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite();
let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM));
let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
let mut run: Vec<(f64, f64)> = Vec::new();
for w in pts.windows(2) {
let seg = match w {
[Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)),
_ => None,
};
match seg {
Some((p, q, cut_start, cut_end)) => {
if cut_start || run.is_empty() {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
run.push(p);
}
run.push(q);
if cut_end {
runs.push(std::mem::take(&mut run));
}
}
None => {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
}
}
}
if run.len() >= 2 {
runs.push(run);
}
runs
}
/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to
/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels.
/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing.
pub(super) fn draw_trendlines(
ctx: &mut Ctx,
s: &SeriesData,
pts: &[(f64, f64)],
frac: &dyn Fn(f64, f64) -> (f64, f64),
px: &dyn Fn((f64, f64)) -> [f32; 2],
labels: &mut Vec<String>,
window: (f64, f64),
) {
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
let spec = &t.spec;
let samples: Vec<Option<(f64, f64)>> = match spec.kind {
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
_ => {
let Some(fit) = fit_trendline(spec, pts) else { continue };
// Sample only the part of the forecast that is on the plot.
let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1));
if spec.display_equation {
labels.push(fit.equation());
}
if spec.display_r_squared {
labels.push(fit.r_squared());
}
(0..=TREND_STEPS)
.map(|i| {
let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64;
fit.eval(x).map(|y| frac(x, y))
})
.collect()
}
};
let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0);
let dash = spec.dash != Some(LineDash::Solid);
for run in clip_unit(&samples) {
let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS);
if line.len() >= 2 {
ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash });
}
}
}
}
/// Trendline equation / R² labels, stacked in the plot's top-right corner.
pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
let lh = LABEL_SIZE * 1.3;
for (i, t) in labels.iter().enumerate() {
let y = plot.y + 4.0 + i as f32 * lh;
if y + lh > plot.bottom() {
break;
}
let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0);
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top));
}
}
+3 -43
View File
@@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
const ALL_KINDS: [ChartKind; 26] = [
const ALL_KINDS: [ChartKind; 25] = [
ChartKind::ColumnClustered,
ChartKind::ColumnStacked,
ChartKind::ColumnStacked100,
@@ -19,7 +19,6 @@ const ALL_KINDS: [ChartKind; 26] = [
ChartKind::Doughnut,
ChartKind::Area,
ChartKind::AreaStacked,
ChartKind::AreaStacked100,
ChartKind::Scatter,
ChartKind::ScatterLines,
ChartKind::Bubble,
@@ -77,7 +76,7 @@ fn sample(kind: ChartKind) -> ChartData {
ChartData {
category_levels: vec![],
palette: vec![],
categories: cats.iter().map(ToString::to_string).collect(),
categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
b,
@@ -1074,7 +1073,7 @@ fn pie_points_follow_workbook_theme() {
#[test]
fn area_charts_draw_source_value_labels_only_when_enabled() {
for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] {
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
let d = sample(kind);
let mut c = chart(kind);
c.data_labels = false;
@@ -1098,45 +1097,6 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
}
}
#[test]
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100),
series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100),
],
};
let mut c = chart(ChartKind::AreaStacked100);
c.data_labels = false;
let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let t = texts(&p);
assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}");
assert!(!t.contains(&"40"), "{t:?}");
let polys: Vec<&Vec<[f32; 2]>> = p
.iter()
.filter_map(|p| match p {
Prim::Polygon { pts, .. } => Some(pts),
_ => None,
})
.collect();
let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") };
// Each polygon is its top edge left to right, then its base right to left.
let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN);
let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN);
let (y0, y100) = (base(north, 0), top(south, 0));
assert!(y0 > y100, "{y0} {y100}");
let frac = |y: f32| (y0 - y) / (y0 - y100);
for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] {
assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j)));
assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j)));
}
// A category whose total is zero stays on the baseline.
assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2)));
}
#[test]
fn two_level_categories_resolve_and_draw() {
let years = ["2023", "", "", "2024", "", ""];
+21 -40
View File
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
chars.next();
letters += 1;
while chars.peek().is_some_and(char::is_ascii_digit) {
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
chars.next();
}
}
@@ -295,16 +295,15 @@ mod tests {
use super::*;
#[test]
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
fn range_parse_rejects_signed_rows() {
assert!(RangeRef::parse("+1:+3").is_none());
assert!(RangeRef::parse("-1:3").is_none());
assert!(RangeRef::parse("$$1:3").is_none());
assert!(RangeRef::parse(":3").is_none());
assert!(RangeRef::parse("$$A:$$C").is_none());
assert!(RangeRef::parse("$A:$C").is_some());
let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?;
let r = RangeRef::parse("$1:$3").unwrap();
assert_eq!((r.start.row, r.end.row), (0, 2));
Ok(())
}
#[test]
@@ -351,21 +350,6 @@ mod tests {
assert_eq!(split_sheet("A1"), (None, "A1"));
}
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
/// reference.
#[test]
fn split_sheet_leaves_device_names_inert_in_the_body() {
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
let (sheet, rest) = split_sheet(s);
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
let _ = sheet;
}
// A plain single-letter sheet is still split, not mistaken for a body.
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
}
#[test]
fn letters_roundtrip() {
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
@@ -390,20 +374,18 @@ mod tests {
}
#[test]
fn ranges() -> Result<(), std::io::Error> {
let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range"));
let r = parse("C3:A1")?;
fn ranges() {
let r = RangeRef::parse("C3:A1").unwrap();
assert_eq!(r.a1(), "A1:C3");
assert_eq!(r.count(), 9);
assert_eq!(parse("A:C")?.a1(), "A:C");
assert_eq!(parse("3:5")?.a1(), "3:5");
assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C");
assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5");
assert!(RangeRef::parse("0:5").is_none());
let a = parse("A1:C3")?;
let b = parse("B2:D4")?;
assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3");
let a = RangeRef::parse("A1:C3").unwrap();
let b = RangeRef::parse("B2:D4").unwrap();
assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3");
assert_eq!(a.union(&b).a1(), "A1:D4");
assert_eq!(a.iter().count(), 9);
Ok(())
}
#[test]
@@ -461,19 +443,18 @@ mod category_level_tests {
use super::*;
#[test]
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> {
let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range"));
assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 }));
assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped");
assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list");
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
let r = |s: &str| RangeRef::parse(s).unwrap();
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
// A row of values: levels are rows.
assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None);
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
// Literal values: the categories' own shape decides.
assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns");
Ok(())
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
}
}
+11 -22
View File
@@ -47,11 +47,13 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
}
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap.
/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`.
#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm
pub fn unix_now_ms() -> Option<f64> {
#[cfg(not(target_arch = "wasm32"))]
{
Some(chrono::Utc::now().timestamp_millis() as f64)
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?;
Some(d.as_secs_f64() * 1000.0)
}
#[cfg(target_arch = "wasm32")]
{
@@ -172,9 +174,6 @@ pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if frac_ms >= 86_400_000 {
frac_ms -= 86_400_000;
day += 1;
if day > max_serial(sys) {
return None;
}
}
let (y, m, d, weekday) = match sys {
DateSystem::D1900 => {
@@ -222,14 +221,6 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[test]
fn serial_rounding_into_next_day_past_max_is_none() {
for sys in [DateSystem::D1900, DateSystem::D1904] {
let serial = max_serial(sys) as f64 + 0.9999999995;
assert_eq!(datetime_from_serial(sys, serial), None);
}
}
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
@@ -321,7 +312,7 @@ mod tests {
}
#[test]
fn fictitious_1900_02_29() -> Result<(), std::io::Error> {
fn fictitious_1900_02_29() {
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
// through it.
let s = DateSystem::D1900;
@@ -343,7 +334,7 @@ mod tests {
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
// Every serial up to 1900-12-31 comes back from its own year, month and day.
for serial in 0..=366 {
let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?;
let d = datetime_from_serial(s, serial as f64).unwrap();
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
}
assert_eq!(days_in_month_in(s, 1900, 2), 29);
@@ -356,7 +347,6 @@ mod tests {
assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
Ok(())
}
#[test]
@@ -370,18 +360,17 @@ mod tests {
}
#[test]
fn roundtrip() -> Result<(), std::io::Error> {
fn roundtrip() {
let s = DateSystem::D1900;
for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?;
let d = datetime_from_serial(s, serial).unwrap();
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
}
let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?;
let d = datetime_from_serial(s, 60.0).unwrap();
assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?;
let d = datetime_from_serial(s, 46302.75).unwrap();
assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday
let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?;
let d = datetime_from_serial(s, 1.0).unwrap();
assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
Ok(())
}
}
+3 -23
View File
@@ -371,16 +371,10 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
if let Some((d, f)) = parse_date(t, sys) {
return Some((d, Some(f)));
}
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
// instead of reparsing every prefix for every space (one long text field quadratic).
// date + time: split at the last space(s) before a time-looking tail.
for (i, _) in t.match_indices(' ') {
let Some(b) = t.get(i + 1..) else { continue };
let bytes = b.as_bytes();
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
continue;
}
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
return Some((d + tf, Some("m/d/yyyy h:mm")));
}
}
@@ -406,20 +400,6 @@ mod tests {
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn overlong_time_fields_stay_text() {
// #2168: all-digit field lengths are gated before allocation/derivation,
// so hostile strings never spin the time-derivation machinery.
let big = "9".repeat(500);
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
// Within the gate, times still work.
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
+4 -5
View File
@@ -245,7 +245,7 @@ impl Value {
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
Value::Text(t) => Ok(t.to_string()),
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
}
}
/// Coercion to a boolean as `IF` does it.
@@ -264,7 +264,7 @@ impl Value {
}
}
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
}
}
/// Display text with General formatting (for logs, CSV and tests).
@@ -454,12 +454,11 @@ mod tests {
}
#[test]
fn arrays() -> Result<(), std::io::Error> {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).ok_or_else(|| std::io::Error::other("valid array"))?;
fn arrays() {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0)));
assert!(Array::new(2, 2, vec![]).is_none());
let row = Array::row(vec![1.0.into(), 2.0.into()]);
assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0));
Ok(())
}
}
-1
View File
@@ -17,7 +17,6 @@ gridcraft-model = { workspace = true }
gridcraft-calc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
web-time = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
gridcraft-xlsx = { workspace = true }
+78 -96
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use gridcraft_core::{CellRef, RangeRef, Value, compare};
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
use gridcraft_model::*;
/// The conditional look of one cell.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -136,95 +136,6 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
pub use gridcraft_model::icon_count;
struct RuleEval<'a> {
wb: &'a Workbook,
sheet: usize,
cell: CellRef,
first: CellRef,
value: &'a Value,
text: &'a str,
stats: &'a RuleStats,
}
fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> {
let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx;
match rule {
CfRule::CellIs { op, a, b, style } => {
let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell));
let av = eval(a);
let matches = match op {
CfOperator::Between | CfOperator::NotBetween => {
let bv = b.as_deref().map(eval).unwrap_or_default();
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater;
if *op == CfOperator::Between { within } else { !within }
}
CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal,
CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal,
CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater,
CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less,
CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater,
};
(matches && !value.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
CfRule::ContainsText { text: needle, style } => {
(!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: needle, style } => {
(value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: needle, style } => {
(!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: needle, style } => {
(!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => value.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
}
CfRule::Unique { style } => {
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
}
_ => None,
}
}
fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool {
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let day = serial.floor();
let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial);
let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0);
let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1));
let year_month = date(day).map(|value| (value.year, value.month));
let month_matches = |offset: i32| {
let shifted_month = month as i32 + offset;
let shifted_year = year + (shifted_month - 1).div_euclid(12);
let shifted_month = (shifted_month - 1).rem_euclid(12) + 1;
year_month == Some((shifted_year, shifted_month as u32))
};
match period {
"yesterday" => day == today - 1.0,
"today" => day == today,
"tomorrow" => day == today + 1.0,
"last7Days" => day > today - 7.0 && day <= today,
"lastWeek" => day >= week_start - 7.0 && day < week_start,
"thisWeek" => day >= week_start && day < week_start + 7.0,
"nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0,
"lastMonth" => month_matches(-1),
"thisMonth" => month_matches(0),
"nextMonth" => month_matches(1),
_ => false,
}
}
impl CfCache {
pub fn new() -> Self {
Self::default()
@@ -250,8 +161,52 @@ impl CfCache {
None => &no_stats,
};
let text = v.display();
let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st };
let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule {
let matched_style: Option<&Style> = match &cf.rule {
CfRule::CellIs { op, a, b, style } => {
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c));
let av = eval(a);
let ok = match op {
CfOperator::Between | CfOperator::NotBetween => {
let bv = b.as_deref().map(eval).unwrap_or_default();
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater;
if *op == CfOperator::Between { inside } else { !inside }
}
CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal,
CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal,
CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater,
CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less,
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
};
// As in Excel, a blank cell compares as 0 (or ""), and an error never matches.
(ok && !v.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
// As in Excel, an error value never contains, begins or ends with anything (its rule
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
CfRule::ContainsText { text: t, style } => {
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: t, style } => {
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: t, style } => {
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
}
CfRule::Unique { style } => {
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
}
CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() {
Some(n) if !st.sorted.is_empty() => {
let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize };
@@ -274,8 +229,36 @@ impl CfCache {
None => None,
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
_ => None,
Some(n) => {
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let d = n.floor();
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
let week_start = today - wd;
let (ty, tm) =
gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1));
let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month));
let month_off = |k: i32| {
let m = tm as i32 + k;
let y = ty + (m - 1).div_euclid(12);
let m = (m - 1).rem_euclid(12) + 1;
ym == Some((y, m as u32))
};
let ok = match period.as_str() {
"yesterday" => d == today - 1.0,
"today" => d == today,
"tomorrow" => d == today + 1.0,
"last7Days" => d > today - 7.0 && d <= today,
"lastWeek" => d >= week_start - 7.0 && d < week_start,
"thisWeek" => d >= week_start && d < week_start + 7.0,
"nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0,
"lastMonth" => month_off(-1),
"thisMonth" => month_off(0),
"nextMonth" => month_off(1),
_ => false,
};
ok.then_some(style.as_ref())
}
None => None,
},
CfRule::ColorScale { stops } => {
if let Some(n) = v.as_f64() {
@@ -346,8 +329,7 @@ impl CfCache {
}
None
}
_ => None,
});
};
if let Some(st) = matched_style {
any = true;
// Every matching rule applies; where two set the same part, the rule with the
+1 -22
View File
@@ -267,12 +267,6 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
}
}
/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so
/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls
/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic
/// when a paste holds many bare `&`).
const ENTITY_WINDOW: usize = 12;
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
fn decode(raw: &str) -> String {
@@ -281,9 +275,7 @@ fn decode(raw: &str) -> String {
while let Some(amp) = rest.find('&') {
out.push_str(rest.get(..amp).unwrap_or_default());
rest = rest.get(amp..).unwrap_or_default();
// `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte
// boundary used to fall back to the complete suffix, re-scanning per '&'.
let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.find(';'));
let semi = rest.get(..12).unwrap_or(rest).find(';');
let entity = semi.and_then(|e| rest.get(1..e));
let ch = entity.and_then(|e| match e {
"amp" => Some('&'),
@@ -390,19 +382,6 @@ mod tests {
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn entities_recognized_only_within_the_twelve_byte_window() {
// The window never scans the rest of the text for a semicolon: a bare '&' followed by
// long plain text stays a bare '&', and one '&' after another is linear.
assert_eq!(decode("a&amplifier; b"), "a&amplifier; b");
assert_eq!(decode("&ampampampampAMP;"), "&ampampampampAMP;");
// Inside the window the usual forms still decode, and a trailing bare '&' too.
assert_eq!(decode("&#38;"), "&");
assert_eq!(decode("&#x266A;"), "\u{266A}");
assert_eq!(decode("a&amp;b&nbsp;c"), "a&b\u{a0}c");
assert_eq!(decode("amp&"), "amp&");
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
+2 -2
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet};
use serde_json::{Value as Json, json};
use super::whatif::parse_range;
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param};
use super::*;
const CMD: &str = "data.analysisToolPak";
/// Most input cells a tool reads.
@@ -1182,7 +1182,7 @@ mod tests {
fn v(s: &Session, a: &str) -> Value {
let d = s.doc().unwrap();
let (sh, body) = crate::cmd::split_sheet(a);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
}
fn n(s: &Session, a: &str) -> f64 {
File diff suppressed because it is too large Load Diff
-319
View File
@@ -1,319 +0,0 @@
use super::sort::sort_key;
use super::{
AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param,
current_region, data_target, edit, json,
};
// ---------------------------------------------------------------- filter
pub(super) fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.filter")?;
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start };
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
if let Some(name) = in_table {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) {
t.filter_button = !t.filter_button;
if !t.filter_button {
t.filter.clear();
}
}
Ok(Json::Null)
});
}
if has {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
sh.autofilter = None;
for r in filtered {
if let Some(info) = sh.rows.get_mut(&r) {
info.hidden = false;
}
}
cx.structural = true;
Ok(json!({"filter": false}))
});
}
install_filter(s, p)
}
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, mut r) = data_target(s, p, "data.filter")?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let below = current_region(sh, r.start).end.row;
if below > r.end.row {
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
Ok(json!({"filter": true, "range": r.a1()}))
})
}
pub(super) fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let af = match &sh.autofilter {
Some(a) => a.clone(),
None => {
install_filter(s, &json!({}))?;
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
}
};
let col = match col_param(p.get("column"), af.range.start.col) {
Some(c) => c,
// A number too large to be a column is outside the filter range, not a missing parameter.
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
None => return Err(bad("data.filterBy", "missing `column`")),
};
if col < af.range.start.col || col > af.range.end.col {
return Err(bad("data.filterBy", "column outside the filter range"));
}
let off = col - af.range.start.col;
let crit = if bool_param(p, "clear").unwrap_or(false) {
None
} else if let Some(vals) = p.get("values").and_then(Json::as_array) {
Some(FilterCriterion::Values {
values: vals.iter().map(crate::cmd::edit::json_to_input).collect(),
blanks: bool_param(p, "blanks").unwrap_or(false),
})
} else if let Some(c) = p.get("custom") {
let a =
(c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default());
let b =
c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(crate::cmd::edit::json_to_input).unwrap_or_default()));
Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) })
} else if let Some(t) = p.get("top") {
Some(FilterCriterion::Top10 {
bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false),
percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false),
count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32,
})
} else {
bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage)
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
a.criteria.retain(|(o, _)| *o != off);
if let Some(c) = crit.clone() {
a.criteria.push((off, c));
}
}
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})?;
let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0);
Ok(json!({"hiddenRows": hidden}))
}
/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)?
fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
let num = gridcraft_core::parse::parse_number_text(crit);
let cmp = match (v.as_f64(), num) {
(Some(a), Some(b)) => a.partial_cmp(&b),
// A numeric ordering comparison only matches numbers; blanks and text never do.
(None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None,
_ => Some(gridcraft_core::compare_text(text, crit)),
};
let wild = || crate::cmd::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase());
match op {
"=" => {
if crit.contains(['*', '?']) {
wild()
} else {
cmp == Some(Ordering::Equal)
}
}
"<>" => {
if crit.contains(['*', '?']) {
!wild()
} else {
cmp != Some(Ordering::Equal)
}
}
">" => cmp == Some(Ordering::Greater),
"<" => cmp == Some(Ordering::Less),
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
"beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()),
"endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()),
"contains" => text.to_lowercase().contains(&crit.to_lowercase()),
"notContains" => !text.to_lowercase().contains(&crit.to_lowercase()),
_ => true,
}
}
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
return;
}
let Some(af) = sh.autofilter.as_ref() else { return };
let r = af.range;
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
return;
}
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
if let Some(af) = sh.autofilter.as_mut() {
af.range = grown;
}
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
let Some(af) = sh.autofilter.clone() else { return };
let r = af.range;
let mut hide = Vec::new();
// Precompute per-criterion top10 threshold and average once, not per row.
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
for (i, (off, c)) in af.criteria.iter().enumerate() {
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
let col = r.start.col + off;
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
let thr = match c {
FilterCriterion::Top10 { bottom, percent, count } => {
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
let k = k.clamp(1, nums.len().max(1));
nums.get(k - 1).copied()
}
_ => None,
};
col_stats.insert(i, (thr, avg));
}
}
for row in r.start.row + 1..=r.end.row {
let mut visible = true;
for (i, (off, crit)) in af.criteria.iter().enumerate() {
let c = CellRef::new(row, r.start.col + off);
let v = sh.value(c);
let text = crate::display::cell_text(wb, sh, c);
let ok = match crit {
FilterCriterion::Values { values, blanks } => {
if filter_blank(&v) {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
}
}
FilterCriterion::Custom { a, b, and } => {
let x = custom_ok(&v, &text, &a.0, &a.1);
match b {
Some(b) => {
let y = custom_ok(&v, &text, &b.0, &b.1);
if *and { x && y } else { x || y }
}
None => x,
}
}
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((thr, _))) => {
let thr = thr.unwrap_or(n);
if *bottom { n <= thr } else { n >= thr }
}
_ => false,
},
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((_, avg))) => {
if *above {
n > *avg
} else {
n < *avg
}
}
_ => false,
},
FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col,
FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col,
// Icons are not evaluated here: the row keeps its current visibility.
FilterCriterion::Icon { .. } => !sh.is_row_hidden(row),
};
if !ok {
visible = false;
break;
}
}
hide.push((row, !visible));
}
let Some(shm) = wb.sheet_mut(sheet) else { return };
for (row, h) in hide {
if h {
shm.rows.entry(row).or_default().hidden = true;
} else if let Some(e) = shm.rows.get_mut(&row) {
e.hidden = false;
}
}
}
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
a.criteria.clear();
}
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})
}
/// Distinct display values of a filter column (for the dropdown list).
pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> {
let Some(sh) = wb.sheet(sheet) else { return vec![] };
let Some(af) = &sh.autofilter else { return vec![] };
let off = col.saturating_sub(af.range.start.col);
let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c);
let mut seen = std::collections::BTreeMap::new();
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
let c = CellRef::new(row, col);
let v = sh.value(c);
let blank = filter_blank(&v);
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
let on = match selected {
Some(FilterCriterion::Values { values, blanks }) => {
if blank {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
}
}
_ => true,
};
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
seen.entry((key, t)).or_insert(on);
}
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
-270
View File
@@ -1,270 +0,0 @@
use super::{
CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines,
str_param, target_sheet,
};
// ---------------------------------------------------------------- outline
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
return Ok((true, r.start.row, r.end.row));
}
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
return Ok((false, r.start.col, r.end.col));
}
let r = s.doc()?.selection.current();
match str_param(p, "by") {
Some("rows") => Ok((true, r.start.row, r.end.row)),
Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)),
_ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)),
_ => Ok((true, r.start.row, r.end.row)),
}
}
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.
if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
let e = map.entry(i).or_default();
e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) };
if !on && e.outline == 0 {
e.hidden = false;
}
}
// A summary line whose group is gone has nothing left to collapse.
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
if !on
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
{
e.collapsed = false;
}
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or
/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the
/// + 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)?;
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();
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let all = outline::groups_with(map, before);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0);
hits.into_iter().filter(|g| g.level == deepest).collect()
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
}
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if !g.has_summary() {
continue;
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
cx.structural = true;
Ok(Json::Null)
})
}
/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as
/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the
/// group expanded and leave it invisible (#1714).
fn set_hidden(e: &mut LineInfo, hidden: bool) {
e.hidden = hidden;
if !hidden && e.size == Some(0.0) {
e.size = None;
}
}
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8);
let (rows, cols) = (level("rows"), level("cols"));
if rows.is_none() && cols.is_none() {
return Err(EngineError::BadParams {
cmd: "data.showLevels".into(),
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
let Some(n) = n else { continue };
let groups = outline::groups_with(map, before);
for e in map.values_mut().filter(|e| e.outline > 0) {
set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups.into_iter().filter(|g| g.has_summary()) {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
cx.structural = true;
Ok(Json::Null)
})
}
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
pub(super) fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
let Some(on) = bool_param(p, key) else { continue };
let was_before = outline::summary_before(sh, rows);
if rows {
sh.summary_below = on;
} else {
sh.summary_right = on;
}
if was_before != on {
continue;
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
}
pub(super) fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
let func = str_param(p, "function").unwrap_or("sum");
let code = match func {
"count" => 3,
"average" => 1,
"max" => 4,
"min" => 5,
"product" => 6,
_ => 9,
};
let cols: Vec<u32> = p
.get("columns")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
.unwrap_or_else(|| vec![r.end.col]);
let label = match func {
"count" => "Count",
"average" => "Average",
"max" => "Max",
"min" => "Min",
"product" => "Product",
_ => "Total",
};
// Group boundaries (header in the first row).
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let mut groups: Vec<(u32, u32, String)> = Vec::new();
let first = r.start.row + u32::from(header);
let mut start = first;
for row in first..=r.end.row + 1 {
let cur = sh.value(CellRef::new(row, group_col)).display();
let prev = sh.value(CellRef::new(start, group_col)).display();
if row > r.end.row || cur != prev {
groups.push((start, row - 1, prev));
start = row;
}
}
// The totals go below each group, so the outline puts summary rows below their detail (the
// Subtotal dialog's "Summary below data"), in the same undo step.
if !sh.summary_below {
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
}
// Insert a total row after each group, bottom-up so indices stay valid.
let mut inserted = 0;
for (g0, g1, name) in groups.iter().rev() {
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
let row = g1 + 1;
let lbl = CellRef::new(row, group_col).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
s.execute(
"home.bold",
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
)?;
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
inserted += 1;
}
let last = r.end.row + inserted + 1;
let lbl = CellRef::new(last, group_col).a1();
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
Ok(json!({"groups": inserted}))
}
-325
View File
@@ -1,325 +0,0 @@
use super::{
Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet,
Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet,
};
#[derive(Clone)]
struct SortKey {
col: u32,
desc: bool,
by: String,
list: Option<Vec<String>>,
}
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if let Some(t) = str_param(p, "range")
&& let (Some(name), _) = split_sheet(t)
&& d.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
}
pub(super) fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
let (sheet, r) = sort_target(s, p)?;
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = guess_header(&d.wb, sh, r);
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
}
pub(super) fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = sort_target(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let by_cols = str_param(p, "orientation") == Some("columns");
let case = bool_param(p, "matchCase").unwrap_or(false);
let mut keys: Vec<SortKey> = Vec::new();
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
let col = if by_cols {
let row = match k.get("row").and_then(Json::as_u64) {
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
Some(n) => {
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
}
None => None,
};
row.or_else(|| col_param(k.get("column"), r.start.row))
} else {
col_param(k.get("column"), r.start.col)
};
let Some(col) = col else { continue };
keys.push(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
});
}
if keys.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
return ok();
}
if keys.len() > 64 {
return Err(bad("data.sort", "too many sort levels (64 max)"));
}
do_sort(s, sheet, r, header, keys, by_cols, case)
}
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
crate::cmd::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
let body = if by_cols {
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
} else if header && r.height() > 1 {
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
} else {
r
};
if body.count() > 20_000_000 {
return Err(bad("data.sort", "range too large"));
}
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into()));
}
let n = if by_cols { body.width() } else { body.height() };
let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) };
let theme = cx.wb.theme.clone();
let styles = &cx.wb.styles;
let key_val = |i: u32, k: &SortKey| -> (Value, u32) {
let c = line(i, k.col);
let v = sh.value(c);
let color = match k.by.as_str() {
"cellColor" => {
styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX)
}
"fontColor" => {
styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX)
}
_ => 0,
};
(v, color)
};
let mut order: Vec<u32> = (0..n).collect();
let keyed: Vec<Vec<(Value, u32)>> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect();
order.sort_by(|&a, &b| {
for (ki, k) in keys.iter().enumerate() {
let (va, ca) = &keyed[a as usize][ki];
let (vb, cb) = &keyed[b as usize][ki];
let o = if k.by != "values" {
ca.cmp(cb)
} else if let Some(list) = &k.list {
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
pos(va).cmp(&pos(vb))
} else if case && va.is_text() && vb.is_text() {
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
} else {
sort_compare(va, vb)
};
// Blanks always sort last, whatever the order.
let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o };
if o != Ordering::Equal {
return o;
}
}
Ordering::Equal
});
if order.iter().enumerate().all(|(i, &o)| i as u32 == o) {
return Ok(Json::Null);
}
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
// describe — the way cell pictures and row heights do. Collect them before inserting,
// so a swap can't resurrect one from a destination that is also a source.
let side: Vec<(CellRef, Comment)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
.collect();
let links: Vec<(CellRef, Hyperlink)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
.collect();
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
let mut pictures = Vec::new();
for (dst_i, &src_i) in order.iter().enumerate() {
for &k in &lanes {
let src = line(src_i, k);
let dst = line(dst_i as u32, k);
let mut cell = sh.cell(src).cloned();
if let Some(c) = cell.as_mut()
&& let Some(f) = &c.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64);
c.formula = Some(Arc::new(Formula::from_expr(shifted)));
}
if let Some(picture) = sh.cell_pictures.get(&src) {
pictures.push((dst, picture.clone()));
}
moved.push((dst, cell));
}
}
// Row heights travel with rows.
let heights: Vec<Option<LineInfo>> =
if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() };
// Floating objects anchored inside the sorted body travel with their lines, unless they
// are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering
// is the inverse of `order`: `dst_of[src] = dst`.
let dst_of: Vec<u32> = {
let mut v = vec![0u32; order.len()];
for (dst_i, &src_i) in order.iter().enumerate() {
if let Some(slot) = v.get_mut(src_i as usize) {
*slot = dst_i as u32;
}
}
v
};
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
// can be re-fitted to the lines it spans after the sort.
let objs: Vec<Anchor> =
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
let descs: Vec<(u32, u32, f32, f32)> = objs
.iter()
.map(|a| {
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
let tc = sh.col_at(x2);
let tr = sh.row_at(y2);
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
})
.collect();
let shm = cx.sheet_mut(sheet)?;
for (c, cell) in &moved {
match cell {
Some(x) => shm.set_cell(*c, x.clone()),
None => {
shm.remove_cell(*c);
}
}
}
// Every cell of the body is the destination of exactly one source cell, so clearing the
// body and inserting the permuted entries is the same permutation the cells went through.
for c in body.iter() {
shm.comments.remove(&c);
shm.hyperlinks.remove(&c);
}
for (at, cm) in side {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
shm.cell_pictures.extend(pictures);
for (i, h) in heights.into_iter().enumerate() {
let row = body.start.row + i as u32;
match h {
Some(info) => {
shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info });
}
None => {
if let Some(e) = shm.rows.get_mut(&row) {
e.size = None;
}
}
}
}
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
// Only objects anchored inside the sorted block move: within its lines on the sort axis
// and within its span on the other axis (a picture beside the block stays put).
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
let ni = shm.images.len();
let nch = shm.charts.len();
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 {
continue;
}
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
if na != a {
if i < ni {
if let Some(o) = shm.images.get_mut(i) {
o.anchor = na;
}
} else if i < ni + nch {
if let Some(o) = shm.charts.get_mut(i - ni) {
o.anchor = na;
}
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
o.anchor = na;
}
}
}
for (c, _) in moved {
cx.changed.push((sheet, c));
}
cx.structural = true;
Ok(Json::Null)
})?;
Ok(json!({"sorted": body.a1()}))
}
/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the
/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
}
let (tc, tr, tdx, tdy) = bottom;
let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } };
let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) };
if axis_start < b0 || axis_start > b1 {
return a;
}
let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) };
if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 {
let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) };
let x1 = sh.col_left(nc) + a.dx as f64;
let y1 = sh.row_top(nr) + a.dy as f64;
let x2 = sh.col_left(nbc) + tdx as f64;
let y2 = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
} else {
Anchor { cell: CellRef::new(nr, nc), ..a }
}
}
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
/// before uppercase at the first letter that differs in case.
fn case_order(a: &str, b: &str) -> Ordering {
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
}
pub(super) fn sort_key(v: &Value) -> (u8, i64, String) {
match v {
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
Value::Text(t) => (1, 0, t.to_lowercase()),
Value::Bool(b) => (2, *b as i64, String::new()),
Value::Empty => (9, 0, String::new()),
_ => (3, 0, v.display()),
}
}
-509
View File
@@ -1,509 +0,0 @@
use super::*;
#[test]
fn filter_blanks_include_empty_text_and_formula_results() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B5"})).unwrap();
let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();
assert_eq!(r["hiddenRows"], json!(2));
let vals = filter_values(&s.doc().unwrap().wb, 0, 1);
// The `=""` result joins (Blanks) instead of showing as its own empty entry.
assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}");
let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect();
assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]);
}
#[test]
fn subtotal_honours_header_false() {
// #131: with header:false the first data row was treated as a header and left out of its
// group's subtotal and the grand total.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!("East"));
assert_eq!(get(&mut s, "A3")["text"], json!("East Total"));
assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows");
assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total"));
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
}
#[test]
fn subtotal_puts_summary_rows_below_their_detail() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.summary_below, "the totals sit below their groups");
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
assert!(!hidden(&s, 3), "East Total stays visible");
// One undo takes the subtotals and the setting back.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(!sh.summary_below);
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "B1:C3"})).unwrap();
for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] {
let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err();
assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}");
}
let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap();
assert_eq!(ok["hiddenRows"], json!(1));
}
#[test]
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
s.execute("selection.set", sel.clone()).unwrap();
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
assert!(sh.tables[0].filter_button, "{sel}");
assert!(!sh.is_row_hidden(1), "{sel}");
assert!(sh.is_row_hidden(2), "{sel}");
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
}
}
#[test]
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
assert_eq!(range(&s), Some("A1:B4".to_string()));
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A row typed after a blank row does not grow it.
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A cell outside the filter's columns does not either.
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// Empty text is a blank entry, not a value.
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A formula and Ctrl+Enter (range.fill) are entries too.
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
assert_eq!(range(&s), Some("A1:B6".to_string()));
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
assert_eq!(range(&s), Some("A1:B7".to_string()));
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
s.new_workbook();
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
s.new_workbook();
let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi});
let ok =
|s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true);
s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap();
s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap();
assert!(ok(&mut s, "B2", "999"));
assert!(!ok(&mut s, "B1", "999"));
assert!(!ok(&mut s, "B3", "999"));
s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap();
for cell in ["B2", "B3", "B4"] {
assert!(ok(&mut s, cell, "150"), "{cell}");
assert!(!ok(&mut s, cell, "5"), "{cell}");
}
assert!(ok(&mut s, "B1", "5"));
assert!(!ok(&mut s, "B1", "150"));
}
#[test]
fn showing_detail_gives_zero_width_columns_their_width_back() {
// #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them.
let mut s = Session::new();
s.new_workbook();
let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() };
let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for c in 2..=4 {
sh.cols.insert(c, hidden);
}
sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() });
s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
for c in 2..=4 {
assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}");
}
// Hiding again hides them; the level buttons show zero-width columns too.
s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0);
Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden);
s.execute("data.showLevels", json!({"cols": 2})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width));
}
#[test]
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("data.group", json!({"rows": "2:4"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
let hidden = |s: &Session| -> Vec<u32> {
let sh = s.doc().unwrap().wb.active().unwrap();
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
};
let before = hidden(&s);
assert_eq!(before, [1, 2, 3, 9]);
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none());
assert_eq!(hidden(&s), before);
assert_eq!(row_state(&s, 4..=4), [(false, true)]);
}
#[test]
fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap();
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
s.execute("selection.set", json!({"range": "E1"})).unwrap();
// The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice.
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::<Vec<_>>(), [2, 9]);
}
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
fn nested_rows() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:7"})).unwrap();
s
}
/// (hidden, collapsed) of 0-based rows.
fn row_state(s: &Session, rows: std::ops::RangeInclusive<u32>) -> Vec<(bool, bool)> {
let sh = s.doc().unwrap().wb.active().unwrap();
rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect()
}
#[test]
fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Each step is one undoable command.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]);
}
#[test]
fn hide_detail_on_the_summary_row_collapses_the_group_above_it() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]);
}
#[test]
fn show_levels_collapses_and_expands_to_a_level() {
let mut s = nested_rows();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showLevels", json!({"rows": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Columns are separate; no level at all is an error rather than a guess.
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.showLevels", json!({"cols": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden)));
assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed));
assert_eq!(row_state(&s, 4..=4), [(false, false)]);
assert!(s.execute("data.showLevels", json!({})).is_err());
}
#[test]
fn group_by_columns_for_a_block_of_cells() {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("data.group", json!({"by": "cols"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1)));
assert!(sh.rows.values().all(|l| l.outline == 0));
}
#[test]
fn group_shortcuts_are_the_windows_ones() {
let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(sc("data.group"), Some("Alt+Shift+Right"));
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
let mut s = nested_rows();
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// Hide Detail on the summary row collapses the group below it.
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
// Ungrouping the outer group clears its mark on the row above.
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
}
#[test]
fn changing_the_summary_direction_moves_collapsed_marks() {
let mut s = nested_rows();
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
assert!(col_marked(&s, 1), "columns keep their direction");
// One undo step per change; no params leaves the settings as they are.
s.execute("edit.undo", json!({})).unwrap();
assert!(row_state(&s, 3..=3)[0].1);
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
// Nested groups that end on one row share its mark: it moves to the outer group only.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
// Hide and Show Detail keep it collapsed.
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
// A group on the first row has no row above it to mark.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
for row in [0u64, 1 << 32] {
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
let err = s.execute("data.sort", params).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
}
}
#[test]
fn single_row_and_column_parameters() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
s.execute("home.insertColumns", json!({"cols": "B"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "C3"), json!(4.0));
s.execute("home.hideRows", json!({"rows": "1"})).unwrap();
s.execute("data.group", json!({"cols": "C"})).unwrap();
assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err());
s.execute("home.deleteRows", json!({"rows": "2"})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap();
assert_eq!(v(&mut s, "B2"), json!(4.0));
}
#[test]
fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() {
let cases: [(Json, [&str; 4]); 5] = [
(json!([6, 3]), ["abc", "def", "ghij", ""]),
(json!([3, 3]), ["abc", "defghij", "", ""]),
(json!([0]), ["abcdefghij", "", "", ""]),
(json!([20]), ["abcdefghij", "", "", ""]),
(json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]),
];
for (widths, want) in cases {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap();
s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let got: Vec<String> = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect();
assert_eq!(got, want, "{widths}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
}
#[test]
fn remove_duplicates_without_a_valid_column_is_refused() {
// `columns: []` (or only unusable names) used to compare nothing and delete every row
// but the first.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
for columns in [json!([]), json!(["ZZZ"])] {
let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err();
assert!(err.to_string().contains("columns"), "{err}");
}
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
}
#[test]
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
let rules = [
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
("C1", " 1,2,3 ", r#""1,2,3""#),
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
("F1", "$K$1:$K$3", "$K$1:$K$3"),
("G1", "=Colors", "Colors"),
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
];
for (cell, source, _) in rules {
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
}
let accepted = [
("A1", "In progress"),
("B1", "Closed"),
("C1", "2"),
("D1", r#"Say "hi""#),
("E1", "Green"),
("F1", "Blue"),
("G1", "Red"),
("H1", "Green"),
];
let check = |wb: &Workbook| {
for (cell, input) in accepted {
let at = CellRef::parse(cell).unwrap();
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
}
};
let wb = s.doc().unwrap().wb.clone();
check(&wb);
let sheet_xml = |wb: &Workbook| {
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
xml
};
let xml = sheet_xml(&wb);
for (cell, _, saved) in rules {
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
}
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
check(&reopened);
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
}
#[test]
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
// with no error.
let mut s = Session::new();
s.new_workbook();
for params in [
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
] {
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
assert!(e.contains("unknown"), "{params}: {e}");
}
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
for params in [
json!({"range": "A1", "type": "any"}),
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
] {
s.execute("data.validation", params).unwrap();
}
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert_eq!(sh.validations.len(), 3);
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
}
-193
View File
@@ -1,193 +0,0 @@
use super::{
Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json,
ok, str_param, target_range, target_sheet,
};
// ---------------------------------------------------------------- tools
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Rows and columns past the data are blank: only the part with data is compared (whole
// columns compared a million blank rows and reported them as removed duplicates).
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let cols: Vec<u32> = p
.get("columns")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
// With no column to compare, every row key is empty: the first row would survive and every
// other row would be deleted as a duplicate. A column outside the range compares data the
// user didn't select. Excel refuses both.
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
}
let start = r.start.row + header as u32;
let mut removed = 0;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut seen = std::collections::HashSet::new();
let mut keep: Vec<u32> = Vec::new();
// Cells are compared by the text they show, as in Excel: the same date in two formats
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
for row in start..=r.end.row {
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
if seen.insert(key) {
keep.push(row);
} else {
removed += 1;
}
}
let pictures: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
})
.collect();
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
// describe them move with them; the ones on removed rows are dropped with the row.
let notes: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
})
.collect();
let links: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
})
.collect();
let rows: Vec<Vec<Option<Cell>>> =
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
let shm = cx.sheet_mut(sheet)?;
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
shm.remove_cell(CellRef::new(row, c));
}
}
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
let at = CellRef::new(row, c);
shm.comments.remove(&at);
shm.hyperlinks.remove(&at);
}
}
for (i, cells) in rows.into_iter().enumerate() {
for (j, cell) in cells.into_iter().enumerate() {
if let Some(cell) = cell {
shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
}
}
}
shm.cell_pictures.extend(pictures);
for (at, cm) in notes {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
cx.changed.push((sheet, c));
}
Ok(())
})?;
let remaining = (r.end.row + 1 - start) as usize - removed;
Ok(json!({"removed": removed, "remaining": remaining}))
}
pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let sheet = target_sheet(s, p)?;
let mut delims: Vec<char> = p
.get("delimiters")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect())
.unwrap_or_else(|| vec!['\t']);
if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) {
delims.push(o);
}
let fixed: Option<Vec<usize>> =
p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect());
let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false);
let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"');
let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start);
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut out: Vec<(CellRef, String)> = Vec::new();
for (i, row) in (r.start.row..=r.end.row).enumerate() {
let text = sh.value(CellRef::new(row, r.start.col)).display();
let parts: Vec<String> = match &fixed {
Some(w) => {
let chars: Vec<char> = text.chars().collect();
let mut cuts = vec![0];
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
breaks.sort_unstable();
breaks.dedup();
cuts.extend(breaks);
cuts.push(chars.len());
cuts.windows(2)
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
.collect()
}
None => split_delimited(&text, &delims, consecutive, quote),
};
for (j, part) in parts.into_iter().enumerate() {
out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part));
}
}
for (c, t) in out {
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
let shm = cx.sheet_mut(sheet)?;
match cell {
Some(x) => shm.set_cell(c, x),
None => {
shm.remove_cell(c);
}
}
cx.changed.push((sheet, c));
}
Ok(())
})?;
ok()
}
pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_q = false;
let mut last_delim = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == quote {
if in_q && chars.peek() == Some(&quote) {
cur.push(quote);
chars.next();
} else {
in_q = !in_q;
}
continue;
}
if !in_q && delims.contains(&c) {
if consecutive && last_delim {
continue;
}
out.push(std::mem::take(&mut cur));
last_delim = true;
continue;
}
last_delim = false;
cur.push(c);
}
out.push(cur);
out
}
-293
View File
@@ -1,293 +0,0 @@
use super::{
Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad,
bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- validation
fn op_param(s: Option<&str>) -> CfOperator {
match s.unwrap_or("between") {
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"greater" | "greaterThan" => CfOperator::Greater,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
_ => CfOperator::Between,
}
}
/// `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)));
}
if i.end.row < r.end.row {
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
}
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)));
}
out
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
}
}
validations.retain(|d| !d.ranges.is_empty());
}
pub(super) fn validation(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
if bool_param(p, "clear").unwrap_or(false) {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
remove_validation_cells(&mut sh.validations, &ranges);
Ok(Json::Null)
});
}
let Some(kind) = str_param(p, "type") else {
s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({})));
return ok();
};
let kind = match kind {
"whole" => ValidationKind::Whole,
"decimal" => ValidationKind::Decimal,
"list" => ValidationKind::List,
"date" => ValidationKind::Date,
"time" => ValidationKind::Time,
"textLength" => ValidationKind::TextLength,
"custom" => ValidationKind::Custom,
"any" => ValidationKind::Any,
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
};
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
return Err(bad("data.validation", "a list validation needs `formula1`"));
}
const OPERATORS: [&str; 12] = [
"between",
"notBetween",
"equal",
"notEqual",
"greater",
"greaterThan",
"less",
"lessThan",
"greaterOrEqual",
"greaterThanOrEqual",
"lessOrEqual",
"lessThanOrEqual",
];
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
}
let error_style = match str_param(p, "errorStyle") {
None | Some("stop") => ErrorStyle::Stop,
Some("warning") => ErrorStyle::Warning,
Some("information") => ErrorStyle::Information,
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
};
let f1 = str_param(p, "formula1").unwrap_or("");
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
let dv = Validation {
ranges: ranges.clone(),
kind,
op: op_param(str_param(p, "operator")),
f1,
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
input_title: str_param(p, "inputTitle").unwrap_or("").into(),
input_message: str_param(p, "inputMessage").unwrap_or("").into(),
show_input: true,
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
error_style,
show_error: true,
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
remove_validation_cells(&mut sh.validations, &ranges);
sh.validations.push(dv.clone());
Ok(Json::Null)
})
}
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
/// than a reference or a name to evaluate.
fn is_literal_list(f: &str) -> bool {
f.starts_with('"')
|| (!f.contains('!')
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
}
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
fn list_source(f: &str) -> String {
let t = f.trim();
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
return f.trim_start_matches('=').to_string();
}
format!("\"{}\"", t.replace('"', "\"\""))
}
/// List entries of a list validation (literal or from a range).
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
let f = dv.f1.trim();
if is_literal_list(f) {
// A quoted list is a string literal: `""` inside stands for one `"`.
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
Some(body) => body.replace("\"\"", "\""),
None => f.trim_matches('"').to_string(),
};
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
match gridcraft_calc::evaluate(wb, sheet, at, f) {
Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(),
v if !v.is_empty() && !v.is_error() => vec![v.display()],
_ => vec![],
}
}
/// Checks typed input against the validation of a cell. `Ok(None)` = valid.
pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> {
let sh = wb.sheet(sheet)?;
let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?;
if dv.kind == ValidationKind::Any || !dv.show_error {
return None;
}
if input.is_empty() && dv.allow_blank {
return None;
}
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
// The formulas are written for the first cell of the range and see the value being entered,
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
let entered;
let wb = if sh.value(at) == v {
wb
} else {
let mut w = wb.clone();
if let Some(sh) = w.sheet_mut(sheet) {
let style = sh.style_id(at);
sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() });
}
entered = w;
&entered
};
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
let num = |f: &str| eval(f).to_number().ok();
let ok = match dv.kind {
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
ValidationKind::TextLength => {
let n = input.chars().count() as f64;
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
}
_ => match v.as_f64() {
Some(n) => {
if dv.kind == ValidationKind::Whole && n.fract() != 0.0 {
false
} else {
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
}
}
None => false,
},
};
if ok {
return None;
}
let msg = if dv.error_message.is_empty() {
"This value doesn't match the data validation restrictions defined for this cell.".to_string()
} else {
dv.error_message.clone()
};
Some((dv.clone(), msg))
}
/// A formula written for `first`, as seen from `at` (relative references shifted).
fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
if first == at {
return f.to_string();
}
match gridcraft_formula::parse(f.trim_start_matches('=')) {
Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative(
e,
at.row as i64 - first.row as i64,
at.col as i64 - first.col as i64,
)),
Err(_) => f.to_string(),
}
}
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
pub(in super::super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
if first == to {
return f.to_string();
}
let moved = formula_at(f, first, to);
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
let eq = if f.starts_with('=') { "=" } else { "" };
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
}
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
let Some(a) = a else { return false };
match op {
CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)),
CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)),
CfOperator::Equal => n == a,
CfOperator::NotEqual => n != a,
CfOperator::Greater => n > a,
CfOperator::Less => n < a,
CfOperator::GreaterOrEqual => n >= a,
CfOperator::LessOrEqual => n <= a,
}
}
pub(super) fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let input = str_param(p, "input").unwrap_or("");
match check_validation(&d.wb, d.wb.active_sheet(), at, input) {
None => Ok(json!({"ok": true})),
Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})),
}
}
pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let mut bad_cells = Vec::new();
for dv in &sh.validations {
for r in &dv.ranges {
let r = sh.used_range().and_then(|u| r.intersection(&u));
let Some(r) = r else { continue };
for c in r.iter().take(100_000) {
let text = sh.input_text(c);
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
bad_cells.push(c.a1());
}
}
}
}
Ok(json!({"invalid": bad_cells}))
}
+4 -4
View File
@@ -1,10 +1,10 @@
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
use gridcraft_core::CellRef;
use gridcraft_model::{Anchor, Color, Shape, ShapeKind};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
let d = s.doc()?;
let si = d.wb.active_sheet();
let si = d.wb.active_sheet;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
let dx = (minx - sh.col_left(cell.col)) as f32;
@@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
}
fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
let si = s.doc()?.wb.active_sheet();
let si = s.doc()?.wb.active_sheet;
let id = u32_param(p, "id");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
File diff suppressed because it is too large Load Diff
-290
View File
@@ -1,290 +0,0 @@
use super::{
Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json,
locked_cell_error, str_param, target_range, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- entry
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
/// format). Inherits the destination's cell, row or column style, including its number
/// format, before parsing. Errors when the formula can't be parsed.
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
if input.is_empty() {
let c = Cell { value: Value::Empty, formula: None, style };
return Ok(if c.is_blank() { None } else { Some(c) });
}
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
if fmt_is_text {
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
}
let is_formula = input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
let body = input.strip_prefix('=').unwrap_or(input);
let expr = wb.name_call_case(parse_typed_formula(body)?);
check_arg_counts(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
// Formulas whose result is a date/time get a format from functions like TODAY().
if wb.styles.get(style).num_fmt.as_str() == "General" {
let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default();
let auto = if f.starts_with("TODAY(")
|| f.starts_with("DATE(")
|| f.starts_with("EDATE(")
|| f.starts_with("EOMONTH(")
|| f.starts_with("WORKDAY(")
{
Some("m/d/yyyy")
} else if f.starts_with("NOW(") {
Some("m/d/yyyy h:mm")
} else if f.starts_with("TIME(") {
Some("h:mm AM/PM")
} else {
None
};
if let Some(code) = auto {
cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code));
}
}
return Ok(Some(cell));
}
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
if parsed.format.is_none()
&& !input.contains('%')
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
&& let Value::Number(n) = parsed.value
&& n.abs() >= 1.0
{
parsed.value = Value::Number(n / 100.0);
}
let mut style = style;
if let Some(code) = parsed.format {
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
if cur == "General" {
style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code));
}
}
// Wrap text automatically when the input has line breaks (Alt+Enter).
if input.contains('\n') && !wb.styles.get(style).align.wrap {
style = wb.styles.derive(style, |s| s.align.wrap = true);
}
Ok(Some(Cell { value: parsed.value, formula: None, style }))
}
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
/// and refuses a formula that still doesn't parse.
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
// Excel closes missing parentheses for you.
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
}
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
/// unknown functions are skipped.
pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut bad = None;
expr.walk(&mut |e| {
if bad.is_none()
&& let gridcraft_formula::Expr::Call(name, args) = e
&& let Some(spec) = gridcraft_functions::lookup(name)
&& !spec.accepts(args.len())
{
let few = args.len() < spec.min_args;
bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature));
}
});
match bad {
Some(msg) => Err(EngineError::Other(msg)),
None => Ok(()),
}
}
pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
// Both spellings take a string, number or boolean, as typed (#697).
let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
&& sh.is_protected()
&& d.wb.styles.get(sh.style_id(at)).protection.locked
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
let active_sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
// the cell; a cell elsewhere (or on another sheet) gets a one-cell array.
let selected = cx.sel.current();
let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) };
// A table header can't hold an array's value, and a table resizes under it.
if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) {
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
}
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
if let Some(f) = cell.formula.as_mut() {
std::sync::Arc::make_mut(f).array = Some(range);
}
let sh = cx.sheet_mut(sheet)?;
// The array takes the whole range: other cells in it lose their contents.
for c in range.iter().skip(1).take(1_000_000) {
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
let style = old.style;
sh.set_cell(c, Cell { style, ..Cell::default() });
}
}
sh.set_cell(range.start, cell);
cx.touch(sheet, range.start);
return Ok(Json::Null);
}
let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?;
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
None => {
sh.remove_cell(at);
}
}
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
Ok(Json::Null)
})?;
let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
Ok(json!({"cell": at.a1(), "value": v.display()}))
}
pub(crate) fn json_to_input(v: &Json) -> String {
match v {
Json::Null => String::new(),
Json::Bool(b) => {
if *b {
"TRUE".into()
} else {
"FALSE".into()
}
}
Json::Number(n) => n.to_string(),
Json::String(s) => s.clone(),
other => other.to_string(),
}
}
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
let mut count = 0;
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
let cols: Vec<&Json> = match row.as_array() {
Some(a) => a.iter().collect(),
None => vec![row],
};
for (ci, v) in cols.iter().enumerate() {
let Some(at) = start.offset(ri as i64, ci as i64) else { continue };
let input = json_to_input(v);
let cell = match v {
Json::Number(n) => Some(Cell {
value: Value::number(n.as_f64().unwrap_or(0.0)),
formula: None,
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
}),
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
None => {
sh.remove_cell(at);
}
}
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
count += 1;
}
}
Ok(())
})?;
Ok(json!({"cells": count}))
}
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let input = str_param(p, "input").unwrap_or("").to_string();
let origin = s.doc()?.selection.active;
edit(s, |cx| {
// Each cell gets what typing the input there gives: the automatic number format where
// it's General, its own format otherwise, text where it's Text. That depends only on
// the cell's style, so the input is parsed once per style.
let mut by_style: std::collections::HashMap<StyleId, Option<Cell>> = std::collections::HashMap::new();
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
for at in r.iter() {
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
let base = match by_style.get(&style) {
Some(c) => c.clone(),
None => {
let c = input_to_cell(&input, sheet, at, &mut cx.wb)?;
by_style.insert(style, c.clone());
c
}
};
let mut cell = base.unwrap_or_default();
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
}
}
Ok(Json::Null)
})
}
-271
View File
@@ -1,271 +0,0 @@
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,
};
// ---------------------------------------------------------------- clipboard
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if ranges.len() > 1 && cut {
return Err(multiple());
}
// The selection as made (untrimmed), for Insert Copied Cells.
let 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| {
if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
}
};
let (wb, range, areas) = if ranges.len() > 1 {
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
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 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
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
result["html"] = Json::String(html);
}
Ok(result)
}
/// 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
/// into one block at the first area's place, and that block. Formulas travel as their values, as
/// in Excel. `None` when the areas don't line up.
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
let first = *areas.first()?;
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
let mut sorted = areas.to_vec();
if across {
sorted.sort_by_key(|a| a.start.col);
} else if down {
sorted.sort_by_key(|a| a.start.row);
} else {
return None;
}
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
return None;
}
let anchor = sorted.first()?.start;
let (h, w) = if across {
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
} else {
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
};
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
let src = wb.sheet(sheet)?;
let mut out = wb.clone();
let dst = out.sheet_mut(sheet)?;
dst.take_cells(block);
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
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));
}
let mut next = if across { anchor.col } else { anchor.row };
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 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 });
if let Some(picture) = src.cell_pictures.get(&c) {
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 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));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
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);
}
} else {
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
}
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
Some((out, block))
}
pub(super) fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
pub(super) fn cut(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, true)
}
pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result<Json> {
s.clipboard = None;
s.format_painter = None;
ok()
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum What {
All,
Formulas,
Values,
Formats,
Comments,
Validation,
AllExceptBorders,
ColumnWidths,
FormulasAndNumberFormats,
ValuesAndNumberFormats,
}
pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
// External text (from the system clipboard) when it differs from ours.
if let Some(text) = str_param(p, "text")
&& s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n']))
{
return paste_text(s, p, text);
}
if s.clipboard.is_none() {
return Err(EngineError::Other("Nothing to paste.".into()));
}
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
}
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
// only as far as the text's own shape, so spans in the markup can't expand without bound.
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
None => {
sh.remove_cell(c);
}
}
cx.touch(sheet, c);
}
}
if h > 0 && w > 0 {
let end = at.offset_clamped(h as i64 - 1, w as i64 - 1);
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
}
Ok(())
})?;
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
let mut quoted = false;
let mut at_start = true;
while let Some(c) = chars.next() {
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
quoted = false;
}
} else {
field.push(c);
}
continue;
}
match c {
'"' if at_start => {
quoted = true;
at_start = false;
}
'\t' => {
row.push(std::mem::take(&mut field));
at_start = true;
}
'\r' => {}
'\n' => {
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
at_start = true;
}
_ => {
field.push(c);
at_start = false;
}
}
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
rows
}
-96
View File
@@ -1,96 +0,0 @@
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
// ---------------------------------------------------------------- fill
pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let mut r = target_range(s, p)?;
// Whole rows or columns fill only where the sheet has content (nothing on an empty sheet).
if r.is_full_cols() || r.is_full_rows() {
match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) {
Some(u) => r = u,
None => return Ok(Json::Null),
}
}
if r.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.fill", "range too large"));
}
// With one row selected, Fill Down copies from the row above (Excel behaviour).
if dr == 1 && r.height() == 1 {
r.start.row = r.start.row.saturating_sub(1);
}
if dc == 1 && r.width() == 1 {
r.start.col = r.start.col.saturating_sub(1);
}
let src = if dr == 1 {
RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col))
} else if dr == -1 {
RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end)
} else if dc == 1 {
RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col))
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
Ok(Json::Null)
})
}
pub(super) fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?;
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
let mode = match str_param(p, "mode").unwrap_or("series") {
"copy" => crate::fill::FillMode::Copy,
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => crate::fill::FillMode::Series,
};
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.autoFill", "range too large"));
}
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
// into whole columns (`A:A`).
if !crate::fill::holds_source_in_corner(src, target) {
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
}
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, target, mode)?;
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
Ok(Json::Null)
})
}
pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height());
let kind = str_param(p, "type").unwrap_or("linear").to_string();
let step = f64_param(p, "step").unwrap_or(1.0);
let stop = f64_param(p, "stop");
let unit = str_param(p, "dateUnit").unwrap_or("day").to_string();
edit(s, |cx| {
crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?;
Ok(Json::Null)
})
}
pub(super) fn flash_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?;
if n == 0 {
return Err(EngineError::Other(
"We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(),
));
}
Ok(json!({"filled": n}))
}
-184
View File
@@ -1,184 +0,0 @@
use super::cell::input_to_cell;
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
// ---------------------------------------------------------------- find & replace
fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool {
let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) };
let has_wild = w.contains(['*', '?']);
if has_wild {
let pat = if whole { w } else { format!("*{w}*") };
return wildcard(&h, &pat);
}
if whole { h == w } else { h.contains(&w) }
}
/// `*`/`?` wildcard match with `~` escapes.
pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
let t: Vec<char> = text.chars().collect();
let mut p: Vec<(char, bool)> = Vec::new();
let mut it = pat.chars();
while let Some(c) = it.next() {
if c == '~' {
if let Some(n) = it.next() {
p.push((n, true));
}
} else {
p.push((c, false));
}
}
let (mut ti, mut pi) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
ti += 1;
pi += 1;
} else if pi < p.len() && p[pi] == ('*', false) {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == ('*', false) {
pi += 1;
}
pi == p.len()
}
pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
if what.is_empty() {
return Err(bad("edit.find", "missing `what`"));
}
let case = bool_param(p, "matchCase").unwrap_or(false);
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let values = str_param(p, "lookIn") == Some("values");
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");
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()] };
let mut hits: Vec<(usize, CellRef, String)> = Vec::new();
for si in sheets {
let Some(sh) = d.wb.sheet(si) else { continue };
let mut cells: Vec<(CellRef, String)> = sh
.cells
.iter()
.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)
} else {
cell.input_text()
};
matches(&text, &what, case, whole).then_some((c, text))
})
.collect();
if by_cols {
cells.sort_by_key(|(c, _)| (c.col, c.row));
}
hits.extend(cells.into_iter().map(|(c, t)| (si, c, t)));
}
if hits.is_empty() {
return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}")));
}
if all {
let list: Vec<Json> =
hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect();
return Ok(json!({"count": list.len(), "results": list}));
}
// Next hit after the active cell.
let cur = (d.wb.active_sheet(), d.selection.active);
let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) };
let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned();
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);
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
}
pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
let with = str_param(p, "with").unwrap_or("").to_string();
if what.is_empty() {
return Err(bad("edit.replace", "missing `what`"));
}
let case = bool_param(p, "matchCase").unwrap_or(false);
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 active = s.doc()?.selection.active;
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
let mut n = 0;
for si in sheets {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
for (c, text) in cells {
if !all && c != active {
continue;
}
if !matches(&text, &what, case, whole) {
continue;
}
let new = if whole {
with.clone()
} else if case {
text.replace(&what, &with)
} else {
replace_ci(&text, &what, &with)
};
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
None => {
sh.remove_cell(c);
}
}
cx.touch(si, c);
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"replaced": n}))
}
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
/// byte positions in the folded text still line up with the original's character boundaries.
fn folds_in_place(s: &str) -> bool {
s.chars().all(|c| {
let mut folded = c.to_lowercase();
let first = folded.next();
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
})
}
pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String {
let lower = text.to_lowercase();
let w = what.to_lowercase();
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
// boundary: a length check alone misses foldings that keep the total length while shifting
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
return text.replace(what, with);
}
let mut out = String::new();
let mut i = 0;
while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) {
out.push_str(text.get(i..i + pos).unwrap_or(""));
out.push_str(with);
i += pos + w.len();
}
out.push_str(text.get(i..).unwrap_or(""));
out
}
-199
View File
@@ -1,199 +0,0 @@
use super::selection::{select_region, selection_set};
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
/// How many references the Go To dialog keeps for going back to.
pub(super) const GO_TO_RECENT: usize = 10;
/// The most areas one Go To selects.
pub(super) const GO_TO_MAX_AREAS: usize = 2000;
/// How deep a name that refers to other names is followed.
const GO_TO_NAME_DEPTH: usize = 8;
pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
let d = s.doc()?;
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
if target.is_some_and(|t| t != sheet) {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
target = Some(sheet);
named |= by_name;
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
}
}
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
if bool_param(p, "record") == Some(true) && !named {
let d = s.doc_mut()?;
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
d.go_to_recent.insert(0, entry);
d.go_to_recent.truncate(GO_TO_RECENT);
}
Ok(out)
}
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
let mut chars = text.char_indices();
std::iter::from_fn(move || {
if done {
return None;
}
for (i, c) in chars.by_ref() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
let part = text.get(start..i).unwrap_or("").trim();
start = i + 1;
return Some(part);
}
_ => {}
}
}
done = true;
Some(text.get(start..).unwrap_or("").trim())
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
/// do not refer to a range are left out.
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> =
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
})
.collect();
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
out
}
pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("blanks");
let d = s.doc()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() };
if kind == "lastCell" {
let c = sheet.used_range().map(|r| r.end).unwrap_or_default();
return selection_set(s, &json!({"cell": c.a1()}));
}
if kind == "currentRegion" {
return select_region(s, &json!({}));
}
let mut hits: Vec<CellRef> = Vec::new();
if scope.count() > 4_000_000 {
return Err(bad("edit.goToSpecial", "range too large"));
}
for c in scope.iter() {
let cell = sheet.cell(c);
let v = sheet.value(c);
let ok = match kind {
"blanks" => v.is_empty(),
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
"comments" | "notes" => sheet.comments.contains_key(&c),
"errors" => v.is_error(),
"numbers" => v.is_number(),
"text" => v.is_text(),
"visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col),
"conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))),
"dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))),
_ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))),
};
if ok {
hits.push(c);
}
}
if hits.is_empty() {
return Err(EngineError::Other("No cells were found.".into()));
}
let ranges = coalesce(&hits);
let d = s.doc_mut()?;
d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges };
Ok(json!({"selection": d.selection.a1(), "count": hits.len()}))
}
-358
View File
@@ -1,358 +0,0 @@
use super::clipboard::What;
use super::{
Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param,
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(())
}
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") {
"all" => What::All,
"formulas" => What::Formulas,
"values" => What::Values,
"formats" => What::Formats,
"comments" | "notes" => What::Comments,
"validation" => What::Validation,
"allExceptBorders" => What::AllExceptBorders,
"columnWidths" => What::ColumnWidths,
"formulasAndNumberFormats" => What::FormulasAndNumberFormats,
"valuesAndNumberFormats" => What::ValuesAndNumberFormats,
other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))),
};
let op = str_param(p, "operation").unwrap_or("none").to_string();
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
let transpose = bool_param(p, "transpose").unwrap_or(false);
let link = bool_param(p, "link").unwrap_or(false);
if link && !clip.areas.is_empty() {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
}
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;
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
&& sel.width() % w == 0
&& !sel.is_single()
&& cell_param_on(s, p, "at", dest_sheet)?.is_none()
&& !sel.is_full_cols()
&& !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) };
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 mut style_map = std::collections::HashMap::new();
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
if same_doc {
return id;
}
*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_pictures = std::collections::BTreeMap::new();
if cut_move {
// Remove the source first (contents and formats), and fix references to it.
let source = cx.sheet_mut(clip.sheet)?;
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
let taken = source.take_cells(src);
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
// with the block: the destination is written from the clipboard snapshot taken
// before the cut, so removing them here loses nothing and leaves the old place
// clean instead of leaving one merged block and one note behind.
source.comments.retain(|c, _| !src.contains(*c));
source.hyperlinks.retain(|c, _| !src.contains(*c));
source.merges.retain(|m| !src.contains_range(m));
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
moved_formulas = taken;
}
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()) {
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) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
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() };
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() };
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.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() {
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
// Numbers stored as text participate; other text and errors stay as they are.
apply_operation(&op, &old, &mut new);
}
}
// 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 = dv.ranges.first().map_or(sc, |r| r.start);
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);
}
}
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 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 }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
cx.structural = true;
}
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
Ok(())
})?;
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
// formats (cell styles and merged areas, no conditional formats), column widths and notes
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
// except for column widths and notes, which write no cells at all.
let passive =
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
if !same_doc && !passive {
let d = s.doc_mut()?;
d.trust = d.trust.receive(clip.trust);
}
if cut_move {
s.clipboard = None;
}
Ok(json!({"pasted": s.doc()?.selection.a1()}))
}
/// 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) {
use gridcraft_formula::adjust::Edit;
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
// (a cut moves its arrays itself).
let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. });
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
if keys.is_empty() {
continue;
}
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);
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;
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
n.formula = gridcraft_formula::print(&new);
}
}
}
-174
View File
@@ -1,174 +0,0 @@
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
target_sheet,
};
// ---------------------------------------------------------------- selection
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
let Some(p) = sheet.protection.as_ref() else { return true };
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
}
pub(super) fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("sheet").is_some() {
let i = target_sheet(s, p)?;
s.doc_mut()?.wb_switch_sheet(i);
} else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell"))
&& let (Some(_), _) = split_sheet(r)
{
let i = target_sheet(s, p)?;
s.doc_mut()?.wb_switch_sheet(i);
}
let ranges = target_ranges(s, p)?;
let active = cell_param(s, p, "active")?;
let d = s.doc_mut()?;
let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
if !cell_selectable(&d.wb, sh, sel.active) {
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
}
}
d.selection = sel;
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
}
impl crate::DocState {
/// Switches the active sheet, remembering each sheet's selection.
pub fn wb_switch_sheet(&mut self, i: usize) {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet() {
return;
}
let cur = self.wb.active_sheet();
if self.sheet_selections.len() < self.wb.sheets.len() {
self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default());
}
if let Some(slot) = self.sheet_selections.get_mut(cur) {
*slot = self.selection.clone();
}
// View state: set in place, so the workbook is neither copied nor made dirty.
self.wb.set_active_sheet(i);
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
}
}
pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0);
let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0);
let extend = bool_param(p, "extend").unwrap_or(false);
let jmp = bool_param(p, "jump").unwrap_or(false);
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &mut d.selection;
// Extension moves the far corner, not the active cell.
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
jump(sheet, from, dr.signum(), dc.signum())
} else {
let mut t = from.offset_clamped(dr, dc);
let mut guard = 0;
while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 {
let n = t.offset_clamped(dr.signum(), dc.signum());
if n == t {
break;
}
t = n;
guard += 1;
}
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
};
if let Some(m) = sheet.merge_at(to)
&& !extend
{
to = m.start;
}
if !extend && !cell_selectable(&d.wb, sheet, to) {
// Step on to the next selectable cell in the same direction; stay put if there is none.
let (sr, sc) = (dr.signum(), dc.signum());
let mut t = to;
let mut found = false;
if !jmp && (sr, sc) != (0, 0) {
for _ in 0..10_000 {
let n = t.offset_clamped(sr, sc);
if n == t {
break;
}
t = n;
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
found = true;
break;
}
}
}
if !found {
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
}
to = t;
}
if extend {
sel.extend_to(to);
sel.expand_merges(sheet);
} else {
*sel = crate::Selection::at(to);
if let Some(m) = sheet.merge_at(to) {
sel.ranges = vec![m];
}
}
Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}))
}
pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
let fwd = bool_param(p, "forward").unwrap_or(true);
let by_row = bool_param(p, "byRow").unwrap_or(false);
let d = s.doc_mut()?;
d.selection.cycle(fwd, by_row);
Ok(json!({"active": d.selection.active.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) };
let region = current_region(sheet, d.selection.active);
let all = RangeRef::all();
let r = if region.is_single() || d.selection.current() == region { all } else { region };
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] };
Ok(json!({"selection": d.selection.a1()}))
}
pub(super) fn select_region(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let r = current_region(sheet, d.selection.active);
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] };
Ok(json!({"selection": d.selection.a1()}))
}
pub(super) fn select_row(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let r = d.selection.current();
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] };
Ok(json!({"selection": d.selection.a1()}))
}
pub(super) fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let r = d.selection.current();
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] };
Ok(json!({"selection": d.selection.a1()}))
}
-305
View File
@@ -1,305 +0,0 @@
use super::*;
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
/// formula and a hidden name.
fn go_to_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
s.execute("selection.set", json!({"sheet": 0})).unwrap();
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
};
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
define(&mut s, "Rate", "=0.07", "Workbook");
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
wb.names.push(gridcraft_model::DefinedName {
name: "Secret".into(),
scope: None,
formula: "Sheet1!$Z$9".into(),
comment: String::new(),
hidden: true,
});
s
}
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
/// that are not ranges, and hidden names, are not listed.
#[test]
fn go_to_lists_the_named_ranges_of_every_sheet() {
let mut s = go_to_workbook();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
assert_eq!(list["recent"], json!([]), "{list}");
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
assert_eq!(out["selection"], "C3", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// A name of several areas selects all of them.
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
}
/// A name matches in any case and any script, as a formula matches it.
#[test]
fn go_to_matches_a_sheet_name_in_any_case() {
let mut s = go_to_workbook();
s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap();
s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap();
assert_eq!(out["selection"], "D4", "{out}");
// Seen from Sheet2, its own name hides the workbook name it shares.
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
/// each with a comma ... Excel will highlight all the ranges."
#[test]
fn go_to_selects_every_reference_and_name_in_a_list() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
assert_eq!(out["selection"], "A1,B2", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
}
}
#[test]
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
assert_eq!(parts.next(), Some("A1"));
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
assert_eq!(parts.next(), None);
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
let mut s = go_to_workbook();
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
assert!(err.to_string().contains("at most"), "{err}");
let bad = format!("Total{}", ",".repeat(1_000_000));
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
}
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
#[test]
fn go_to_keeps_the_references_entered_newest_first() {
let mut s = go_to_workbook();
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
// Names are listed already; a call without `record` (the Name Box) is not kept.
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
// Going back from Sheet2 to a recent reference returns to its sheet.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
for i in 0..GO_TO_RECENT + 2 {
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
}
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
assert_eq!(get(&mut s, "A3")["value"], json!(true));
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap();
s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap();
s.execute("selection.set", json!({"range": "C2:C4"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "C3")["value"], json!(4.0));
// Past the result Excel shows #N/A; the old content of the range is replaced.
assert_eq!(get(&mut s, "C4")["text"], "#N/A");
let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text());
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
}
#[test]
fn an_array_entered_outside_the_selection_covers_only_its_cell() {
// Found by the command-sequence fuzzer: the array took the selection's range even with
// `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the
// selected cells were wiped.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap();
s.execute("selection.set", json!({"range": "K20:K22"})).unwrap();
s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap();
assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap()));
assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep"));
}
#[test]
fn wildcards() {
assert!(wildcard("hello", "h*o"));
assert!(wildcard("hello", "h?llo"));
assert!(!wildcard("hello", "h?lo"));
assert!(wildcard("a*b", "a~*b"));
assert!(!wildcard("axb", "a~*b"));
assert!(wildcard("", "*"));
}
#[test]
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\""]]);
}
#[test]
fn paste_special_operations_keep_text_errors_and_formulas() {
let mut s = Session::new();
s.new_workbook();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}),
)
.unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], "123");
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
s.execute("selection.set", json!({"range": "A1:E1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.0));
assert_eq!(get(&mut s, "B1")["value"], "Total");
assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A");
assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1");
assert_eq!(get(&mut s, "D1")["value"], json!(8.0));
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
}
#[test]
fn cut_paste_that_overflows_the_grid_moves_nothing() {
// #997: the source was emptied first and the cells that fell off the grid were skipped.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed, the second row would be past the last row.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed it still fits in column XFD.
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
}
#[test]
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
// no longer line up with the original, and the old slicing dropped the tail.
let mut s = Session::new();
s.new_workbook();
let text = "\u{212A}\u{0130}\u{0130}";
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
}
File diff suppressed because it is too large Load Diff
-150
View File
@@ -1,150 +0,0 @@
use super::{
CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok,
str_param, target_ranges, target_sheet,
};
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
}
pub(super) fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
for r in &ranges {
if r.count() > 100_000 {
return Err(bad("insert.checkbox", "range too large"));
}
for c in r.iter() {
let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = cx.wb.styles.derive(st, |s| {
s.num_fmt = NumFmt::new("checkbox");
s.align.h = HAlign::Center;
});
let sh = cx.sheet_mut(si)?;
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
// cell starts unchecked.
let mut cell = sh.cell(c).cloned().unwrap_or_default();
if cell.value.is_empty() && cell.formula.is_none() {
cell.value = Value::Bool(false);
}
cell.style = new;
sh.set_cell(c, cell);
cx.touch(si, c);
}
}
Ok(Json::Null)
})
}
pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let cells: Vec<CellRef> = match cell_param_on(s, p, "cell", si)? {
Some(c) => vec![c],
None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::<Vec<_>>()).collect(),
};
edit(s, |cx| {
for c in cells {
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& cell.formula.is_none()
{
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
Value::Bool(b) if checkbox => Value::Bool(!b),
Value::Empty if checkbox => Value::Bool(true),
_ => continue,
};
cell.value = next;
cx.changed.push((si, c));
}
}
Ok(Json::Null)
})
}
pub(super) fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
let mut params = object_params(p);
if params.get("name").is_none() {
params["name"] = json!(s.doc()?.wb.theme.name.clone());
}
s.execute("pageLayout.theme", params)
}
pub(super) fn theme_fonts(s: &mut Session, p: &Json) -> Result<Json> {
let name = s.doc()?.wb.theme.name.clone();
let colors: Vec<String> = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect();
s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")}))
}
pub(super) fn background(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "clear").unwrap_or(false) {
let si = s.doc()?.wb.active_sheet();
return edit(s, |cx| {
cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background");
Ok(Json::Null)
});
}
let mut params = object_params(p);
params["alt"] = json!("Sheet background");
params["at"] = json!("A1");
s.execute("insert.picture", params)
}
pub(super) fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
let edge = str_param(p, "edge").unwrap_or("grid");
let preset = match edge {
"top" => "top",
"bottom" => "bottom",
"left" => "left",
"right" => "right",
"outline" => "outside",
_ => "all",
};
let mut params = object_params(p);
params["preset"] = json!(preset);
s.execute("home.borders", params)
}
pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active);
let style = d.wb.styles.get(sh.style_id(c)).clone();
edit(s, |cx| {
cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
cx.wb.cell_styles.push((name.clone(), style.clone()));
Ok(json!({"name": name}))
})
}
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
let mut seen: Vec<String> = Vec::new();
for (id, p) in s.journal.iter().rev() {
if id != "cell.set" {
continue;
}
let input = p.get("input").and_then(Json::as_str).unwrap_or("");
if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
for f in e.functions() {
if !seen.iter().any(|x| x == f) {
seen.push(f.to_string());
}
}
}
if seen.len() >= 10 {
break;
}
}
Ok(json!(seen))
}
pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
ok()
}
-262
View File
@@ -1,262 +0,0 @@
use super::{
CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit,
f64_param, json, split_sheet, str_param, u32_param,
};
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet"))
}
fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?;
f(c)?;
Ok(json!({"chart": c.id}))
})
}
pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
let el = str_param(p, "element").unwrap_or("title").to_string();
let on = bool_param(p, "on").unwrap_or(true);
let pos = str_param(p, "position").map(str::to_string);
with_chart(s, p, |c| {
match el.as_str() {
"title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) },
"legend" => {
c.legend = if !on {
LegendPos::None
} else {
match pos.as_deref() {
Some("top") => LegendPos::Top,
Some("left") => LegendPos::Left,
Some("right") => LegendPos::Right,
_ => LegendPos::Bottom,
}
}
}
"dataLabels" => c.data_labels = on,
"gridlines" => c.gridlines = on,
"axisTitles" => {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into()));
}
"smooth" => {
for s in &mut c.series {
s.smooth = on;
}
}
"trendline" => {
let only = u32_param(p, "series").map(|i| i as usize);
if only.is_some_and(|i| i >= c.series.len()) {
return Err(bad("chart.addElement", "no such series"));
}
let targets: Vec<usize> = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect();
if !on {
for i in targets {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.clear();
}
}
return Ok(());
}
let t = trendline_param(p)?;
let eligible: Vec<usize> =
targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect();
if eligible.is_empty() {
return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series"));
}
for i in eligible {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.push(t.clone());
}
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())
})
}
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
/// Element ▸ Trendline choices, plus the Format Trendline options).
fn trendline_param(p: &Json) -> Result<Trendline> {
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
let mut t = Trendline {
forward: num("forward"),
backward: num("backward"),
display_equation: bool_param(p, "displayEquation").unwrap_or(false),
display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false),
..Trendline::default()
};
t.kind = match str_param(p, "type").unwrap_or("linear") {
"linear" => TrendlineKind::Linear,
// Excel's Linear Forecast: a linear trendline projected two periods ahead.
"linearForecast" => {
if t.forward == 0.0 {
t.forward = 2.0;
}
TrendlineKind::Linear
}
"exponential" => TrendlineKind::Exponential,
"logarithmic" => TrendlineKind::Logarithmic,
"power" => TrendlineKind::Power,
"polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8),
"movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)),
other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))),
};
Ok(t)
}
pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {
let n = u32_param(p, "layout").unwrap_or(1);
with_chart(s, p, |c| {
let (legend, labels, grid, axes) = match n {
2 => (LegendPos::Top, true, false, false),
3 => (LegendPos::Bottom, false, true, false),
4 => (LegendPos::Bottom, true, false, false),
5 => (LegendPos::None, false, true, true),
6 => (LegendPos::Right, false, true, true),
_ => (LegendPos::Right, false, true, false),
};
c.legend = legend;
c.data_labels = labels;
c.gridlines = grid;
if axes {
c.x_title.get_or_insert_with(|| "Axis Title".into());
c.y_title.get_or_insert_with(|| "Axis Title".into());
} else {
c.x_title = None;
c.y_title = None;
}
Ok(())
})
}
pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
let explicit: Option<Vec<Color>> =
p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect());
let palette = str_param(p, "palette").unwrap_or("colorful").to_string();
with_chart(s, p, |c| {
for (i, se) in c.series.iter_mut().enumerate() {
se.color = match &explicit {
Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(),
_ => match palette.as_str() {
"monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))),
p if p.starts_with("accent") => {
let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1);
Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800)))
}
_ => None,
},
};
}
Ok(())
})
}
pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = crate::cmd::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series);
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
pub(super) fn move_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let target = match p.get("sheet").and_then(Json::as_str) {
Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?),
None => None,
};
let at = cell_param_on(s, p, "at", target.unwrap_or(si))?;
edit(s, |cx| {
let mut chart = cx.sheet_mut(si)?.charts.remove(ci);
if let Some(a) = at {
chart.anchor.cell = a;
}
let dest = target.unwrap_or(si);
cx.sheet_mut(dest)?.charts.push(chart);
Ok(Json::Null)
})
}
pub(super) fn format_series(s: &mut Session, p: &Json) -> Result<Json> {
let idx = u32_param(p, "series").unwrap_or(0) as usize;
let color = str_param(p, "color").and_then(Color::from_hex);
with_chart(s, p, |c| {
if let Some(se) = c.series.get_mut(idx) {
se.color = color;
}
Ok(())
})
}
-131
View File
@@ -1,131 +0,0 @@
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let ti = match str_param(p, "table") {
Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)),
None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)),
}
.ok_or_else(|| bad("table.resize", "select a cell in a table"))?;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
));
}
let mut cols = Vec::new();
for (i, h) in headers.iter().enumerate() {
let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() };
let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned();
cols.push(existing.unwrap_or(TableColumn {
name,
totals: TotalsFn::None,
totals_label: None,
formula: None,
unique_name: None,
xml: None,
}));
}
t.columns = cols;
t.range = r;
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
let bytes = if let Some(t) = str_param(p, "text") {
t.as_bytes().to_vec()
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({})));
return ok();
};
let text = String::from_utf8_lossy(&bytes);
// JSON array of objects → rows.
let rows: Vec<Vec<Json>> =
if text.trim_start().starts_with('[') {
let arr: Vec<Json> = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?;
let mut keys: Vec<String> = Vec::new();
for o in &arr {
if let Some(m) = o.as_object() {
for k in m.keys() {
if !keys.contains(k) {
keys.push(k.clone());
}
}
}
}
let mut out = vec![keys.iter().map(|k| json!(k)).collect::<Vec<_>>()];
for o in &arr {
out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect());
}
out
} else {
let delim = str_param(p, "delimiter")
.and_then(|d| d.chars().next())
.unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' });
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() };
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?;
let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) };
(u.start.row..=u.end.row)
.map(|r| {
(u.start.col..=u.end.col)
.map(|c| match sh.value(CellRef::new(r, c)) {
Value::Number(n) => json!(n),
Value::Bool(b) => json!(b),
Value::Empty => Json::Null,
v => json!(v.display()),
})
.collect()
})
.collect()
};
if rows.is_empty() || rows.len() > 1_048_576 {
return Err(EngineError::Other("Nothing to import.".into()));
}
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
});
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
s.execute("range.setValues", json!({"range": "A1", "values": rows}))?;
let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1));
let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true}));
let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()}));
Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w}))
}
pub(super) fn from_table_range(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r };
let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r);
from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::<String>()}))
}
pub(super) fn queries(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
Ok(Json::Array(
d.wb.sheets
.iter()
.flat_map(|sh| {
sh.tables
.iter()
.map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)}))
})
.collect(),
))
}
-148
View File
@@ -1,148 +0,0 @@
use super::chart::format_series;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
};
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
return with_shape(s, p, |sh| sh.fill = color);
}
format_series(s, p)
}
pub(super) fn shape_outline(s: &mut Session, p: &Json) -> Result<Json> {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
with_shape(s, p, |sh| sh.line = color)
}
fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<Json> {
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let shapes = &mut cx.sheet_mut(si)?.shapes;
let sh = match id {
Some(id) => shapes.iter_mut().find(|x| x.id == id),
None => shapes.last_mut(),
}
.ok_or_else(|| bad("shape", "no such shape"))?;
f(sh);
Ok(Json::Null)
})
}
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
}
let item = v.remove(i);
let j = match (forward, extreme) {
(true, true) => v.len(),
(false, true) => 0,
(true, false) => (i + 1).min(v.len()),
(false, false) => i.saturating_sub(1),
};
v.insert(j, item);
}
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
match kind.as_str() {
"chart" => {
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
reorder(&mut sh.charts, i, forward, extreme);
}
}
"image" => {
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
reorder(&mut sh.images, i, forward, extreme);
}
}
_ => {
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
reorder(&mut sh.shapes, i, forward, extreme);
}
}
}
Ok(Json::Null)
})
}
pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.charts {
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
match kind {
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
}
}
pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
let shc = sheet.clone();
anchors_of(sheet, &kind)
.into_iter()
.filter(|(id, _)| ids.contains(id))
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
.collect()
};
if geo.len() < 2 {
return Ok(Json::Null);
}
let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min);
let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max);
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
let shc = sheet.clone();
for (id, a) in anchors_of(sheet, &kind) {
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
let (mut x, mut y) = (g.1, g.2);
match how.as_str() {
"left" => x = left,
"right" => x = right - g.3 as f64,
"center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0,
"top" => y = top,
"bottom" => y = bottom - g.4 as f64,
_ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0,
}
let c = CellRef::new(shc.row_at(y), shc.col_at(x));
a.cell = c;
a.dx = (x - shc.col_left(c.col)) as f32;
a.dy = (y - shc.row_top(c.row)) as f32;
}
Ok(Json::Null)
})
}
pub(super) fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("flip").is_some_and(|f| !f.is_null()) {
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
}
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
-146
View File
@@ -1,146 +0,0 @@
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
let cmds: Vec<Json> =
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
}
s.recording = Some(s.journal.len());
Ok(json!({"recording": true}))
}
pub(super) fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string();
let cmds = p.get("commands").cloned().unwrap_or(json!([]));
if !cmds.is_array() {
return Err(bad("automate.newScript", "`commands` must be an array"));
}
edit(s, |cx| {
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
all[name.as_str()] = cmds.clone();
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(json!({"name": name}))
})
}
pub(super) fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let (wb, trusted) = (&d.wb, d.scripts_trusted);
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> = all
.as_object()
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
.unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false}));
}
Ok(Json::Array(list))
}
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
}
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
/// [`run_script`] drops any `OpenUrl` request a step raises.
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
// Scripts are scoped to their workbook: a step that switches the active window would
// point every later step at another open document, so those are refused too.
if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
return Some(format!("scripts can't pass a file `path` to `{id}`"));
}
None
}
pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
let trusted = match p.get("trusted") {
None => true,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
};
s.doc_mut()?.scripts_trusted = trusted;
Ok(json!({"trusted": trusted}))
}
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
let d = s.doc()?;
let all = stash_get(&d.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
return Err(bad(
"automate.runScript",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
));
}
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if let Some(why) = denied_script_step(id, &st["params"]) {
return Err(bad("automate.runScript", why));
}
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
let before = s.ui_requests.len();
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
let mut i = 0;
s.ui_requests.retain(|r| {
i += 1;
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
n += 1;
}
Ok(json!({"ran": n}))
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
}
pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
if let Some(o) = all.as_object_mut() {
o.remove(&name);
}
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(Json::Null)
})
}
-187
View File
@@ -1,187 +0,0 @@
use super::{
CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet,
stash_get, stash_map, stash_set, str_param, target_sheet, u32_param,
};
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
pub(super) fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
if let Some(sh) = wb.sheet_mut(si) {
sh.view = mode;
}
if !dirty {
d.saved = d.wb.clone();
}
Ok(json!({"view": mode.as_str()}))
}
pub(super) fn ruler(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.hide_ruler);
s.hide_ruler = !on;
Ok(json!({"on": on}))
}
/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current
/// list when they swap in another snapshot of the workbook.
pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>) {
let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned();
let keep = find(from);
if keep == find(to) {
return;
}
let wb = std::sync::Arc::make_mut(to);
wb.names.retain(|n| n.name != WATCH_NAME);
wb.names.extend(keep);
}
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_map(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
Ok(())
})?;
} else if let Some(name) = str_param(p, "show") {
let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?;
if let Some(si) = v["sheet"].as_u64() {
s.execute("sheet.activate", json!({"sheet": si}))?;
}
if let Some(sel) = v["selection"].as_str() {
s.execute("selection.set", json!({"range": sel}))?;
}
if let Some(z) = v["zoom"].as_u64() {
s.execute("view.zoom", json!({"percent": z}))?;
}
if let Some(f) = v.get("freeze") {
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
_ => None,
};
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = freeze;
Ok(())
})?;
}
} else if let Some(name) = str_param(p, "delete") {
if let Some(o) = views.as_object_mut() {
o.remove(name);
}
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
Ok(())
})?;
}
Ok(stash_get(&s.doc()?.wb, VIEWS_NAME))
}
pub(super) fn split(s: &mut Session, _: &Json) -> Result<Json> {
let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some());
if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) }
}
pub(super) fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let id = s.new_window()?;
let doc = s.window(id).map(|w| w.doc_uid);
Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)}))
}
/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most
/// recent window), else the active window.
pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result<u64> {
let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?;
s.target_window(t).map_err(|e| bad(cmd, e.to_string()))
}
/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout,
/// and the interface moves its native windows ([`Session::window_list`] order, active first).
pub(super) fn arrange_all(s: &mut Session, p: &Json) -> Result<Json> {
let arrange = match str_param(p, "arrange").unwrap_or("tiled") {
a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a,
other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))),
};
let active = s.active_window();
let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid);
let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false);
let mut windows: Vec<u64> = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect();
if let Some(at) = windows.iter().position(|w| Some(*w) == active)
&& let Some(first) = windows.get_mut(..=at)
{
first.rotate_right(1);
}
Ok(json!({"arrange": arrange, "windows": windows}))
}
pub(super) fn hide_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = window_param(s, "view.hideWindow", p)?;
s.hide_window(id);
ok()
}
pub(super) fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
Some(window_param(s, "view.unhideWindow", p)?)
} else if let Some(i) = u32_param(p, "index") {
// A workbook's position in the document list.
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?;
s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid))
} else {
s.last_hidden_window()
};
if let Some(id) = id {
s.unhide_window(id);
}
ok()
}
pub(super) fn switch_windows(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
let id = window_param(s, "view.switchWindows", p)?;
s.unhide_window(id);
}
Ok(s.window_list())
}
pub(super) fn watch_window(s: &mut Session, p: &Json) -> Result<Json> {
let mut list: Vec<String> =
stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
let mut changed = false;
if let Some(a) = str_param(p, "add") {
if !list.iter().any(|x| x == a) {
list.push(a.to_string());
}
changed = true;
}
if let Some(r) = str_param(p, "remove") {
list.retain(|x| x != r);
changed = true;
}
if changed {
let v = json!(list.clone());
let d = s.doc_mut()?;
// The watch list is view state: don't add an undo step.
let wb = std::sync::Arc::make_mut(&mut d.wb);
stash_set(wb, WATCH_NAME, &v);
}
let d = s.doc()?;
let rows: Vec<Json> = list
.iter()
.map(|w| {
let (sheet, body) = split_sheet(w);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet());
let c = CellRef::parse(body).unwrap_or_default();
let sh = d.wb.sheet(si);
json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))})
})
.collect();
Ok(Json::Array(rows))
}
+13 -49
View File
@@ -2,7 +2,7 @@
use serde_json::{Value as Json, json};
use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
use super::*;
use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
@@ -25,9 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
options
),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window),
cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window),
cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
]
}
@@ -71,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
// workbook decrypted with its password, so Save never replaces the encrypted original with
@@ -116,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
};
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
@@ -162,16 +160,7 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
/// File › Close closes the active window; the workbook closes with its last window.
fn close(s: &mut Session, p: &Json) -> Result<Json> {
if !bool_param(p, "document").unwrap_or(false)
&& let Some(id) = s.active_window()
&& let Some(doc) = s.window(id).map(|w| w.doc_uid)
&& s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1
{
s.close_window(id);
return Ok(json!({"closed": true, "document": false}));
}
let force = bool_param(p, "force").unwrap_or(false);
let d = s.doc()?;
if d.is_dirty() && !force {
@@ -186,9 +175,9 @@ fn close(s: &mut Session, p: &Json) -> Result<Json> {
fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let d = s.doc()?;
let wb = (*d.wb).clone();
let mut wb = (*d.wb).clone();
if p.get("sheet").is_some() {
wb.set_active_sheet(target_sheet(s, p)?);
wb.active_sheet = target_sheet(s, p)?;
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
@@ -232,35 +221,10 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
}
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) {
Some(i) => {
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?;
s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))?
}
None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => {
return Err(bad("window.activate", "missing `window`"));
}
None => super::extra::window_param(s, "window.activate", p)?,
};
s.unhide_window(id);
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {
return Err(bad("window.activate", "no such window"));
}
s.set_active(i);
ok()
}
fn close_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = super::extra::window_param(s, "window.close", p)?;
let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default();
if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 {
s.close_window(id);
return Ok(json!({"closed": true, "document": false}));
}
let force = bool_param(p, "force").unwrap_or(false);
let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::EngineError::NoDocument)?;
if d.is_dirty() && !force {
let title = d.display_title();
s.activate_window(id);
s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": title})));
return Ok(json!({"closed": false, "dirty": true}));
}
s.close_window(id);
Ok(json!({"closed": true, "document": true}))
}
+6 -6
View File
@@ -11,7 +11,7 @@ use gridcraft_functions::Timeline;
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
use super::*;
use crate::selection::current_region;
const CMD: &str = "data.forecastSheet";
@@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => d.wb.active_sheet(),
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
let r = if r.is_full_cols() || r.is_full_rows() {
@@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)>
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
(d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
}
};
if r.width() < 2 {
@@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let stats_col = width + 1;
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
let at = s.doc()?.wb.active_sheet();
let at = s.doc()?.wb.active_sheet;
let index = edit(s, |cx| {
let at = at.min(cx.wb.sheets.len());
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
@@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc += 1;
}
}
cx.wb.set_active_sheet(at);
cx.wb.active_sheet = at;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(at)
@@ -502,7 +502,7 @@ mod tests {
assert_eq!(r["periods"], 4);
let wb = &s.doc().unwrap().wb;
// Inserted before the data sheet and activated.
assert_eq!(wb.active_sheet(), 0);
assert_eq!(wb.active_sheet, 0);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
+438 -27
View File
@@ -1,13 +1,10 @@
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param,
target_ranges, target_sheet,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -236,23 +233,10 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
// Formatting on a protected sheet keeps the cell's permission flags: a format call
// that flips `locked` from the format permission would authorize content edits that
// the password should still gate, so those flags may not change (Excel keeps that
// checkbox disabled while the sheet is protected).
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
macro_rules! guard_flags {
($old:expr, $new:expr) => {
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
return Err(EngineError::Other(FLAG_GUARD.into()));
}
};
}
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
for r in &ranges {
let full_rows = r.is_full_rows();
@@ -267,7 +251,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
info.and_then(|i| i.style).unwrap_or_default()
};
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
guard_flags!(cur, new);
let sh = cx.sheet_mut(sheet)?;
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
map.entry(line).or_default().style = Some(new);
@@ -277,7 +260,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
for (c, st) in &cells {
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
guard_flags!(*st, new);
cx.sheet_mut(sheet)?.set_style(*c, new);
}
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
@@ -294,7 +276,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
if new != st {
guard_flags!(st, new);
cx.sheet_mut(sheet)?.set_style(c, new);
}
}
@@ -805,15 +786,445 @@ fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result<Json> {
set_fmt(s, p, &new)
}
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
let _ = theme;
let mut s = Style::default();
let accent = |i: u8, tint: i16| Color::Theme(i, tint);
match name {
"Normal" => {}
"Good" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"Bad" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
"Neutral" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"Calculation" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Check Cell" => {
s.fill = Fill::solid(Color::rgb(0xA5, 0xA5, 0xA5));
s.font.color = Color::rgb(0xFF, 0xFF, 0xFF);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Double, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Explanatory Text" => {
s.font.italic = true;
s.font.color = Color::rgb(0x7F, 0x7F, 0x7F);
}
"Input" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xCC, 0x99));
s.font.color = Color::rgb(0x3F, 0x3F, 0x76);
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Linked Cell" => {
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::rgb(0xFF, 0x80, 0x01) };
}
"Note" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xFF, 0xCC));
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xB2, 0xB2, 0xB2) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Output" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0x3F, 0x3F, 0x3F);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Warning Text" => s.font.color = Color::rgb(0xFF, 0x00, 0x00),
"Title" => {
s.font.size = 18.0;
s.font.color = Color::Theme(3, 0);
s.font.name = theme.major_font.clone();
}
"Heading 1" => {
s.font.size = 15.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 0) };
}
"Heading 2" => {
s.font.size = 13.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 500) };
}
"Heading 3" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Medium, color: accent(4, 400) };
}
"Heading 4" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
}
"Total" => {
s.font.bold = true;
s.borders.top = BorderLine { style: BorderStyle::Thin, color: accent(4, 0) };
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: accent(4, 0) };
}
"Currency" => s.num_fmt = NumFmt::new(ACCOUNTING),
"Comma" => s.num_fmt = NumFmt::new(COMMA),
"Percent" => s.num_fmt = NumFmt::new("0%"),
"Currency [0]" => s.num_fmt = NumFmt::new("_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"),
"Comma [0]" => s.num_fmt = NumFmt::new("_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"),
n => {
// AccentN, 20%/40%/60% - AccentN
let (pct, rest) = match n.split_once("% - ") {
Some((p, r)) => (p.parse::<i16>().ok(), r),
None => (None, n),
};
let i: u8 = rest.strip_prefix("Accent")?.parse().ok()?;
if !(1..=6).contains(&i) {
return None;
}
match pct {
None => {
s.fill = Fill::solid(accent(3 + i, 0));
s.font.color = Color::Theme(0, 0);
}
Some(p) => {
s.fill = Fill::solid(accent(3 + i, (1000 - p * 10).clamp(0, 1000)));
s.font.color = if p >= 60 { Color::Theme(0, 0) } else { Color::Theme(1, 0) };
}
}
}
}
Some(s)
}
pub const CELL_STYLE_NAMES: &[&str] = &[
"Normal",
"Bad",
"Good",
"Neutral",
"Calculation",
"Check Cell",
"Explanatory Text",
"Input",
"Linked Cell",
"Note",
"Output",
"Warning Text",
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Title",
"Total",
"20% - Accent1",
"20% - Accent2",
"20% - Accent3",
"20% - Accent4",
"20% - Accent5",
"20% - Accent6",
"40% - Accent1",
"40% - Accent2",
"40% - Accent3",
"40% - Accent4",
"40% - Accent5",
"40% - Accent6",
"60% - Accent1",
"60% - Accent2",
"60% - Accent3",
"60% - Accent4",
"60% - Accent5",
"60% - Accent6",
"Accent1",
"Accent2",
"Accent3",
"Accent4",
"Accent5",
"Accent6",
"Comma",
"Comma [0]",
"Currency",
"Currency [0]",
"Percent",
];
fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("Normal").to_string();
let d = s.doc()?;
let style =
d.wb.cell_styles
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map(|(_, s)| s.clone())
.or_else(|| builtin_cell_style(&name, &d.wb.theme));
let Some(style) = style else { return Err(bad("home.cellStyle", format!("unknown cell style `{name}`"))) };
// Number-format styles only change the number format.
let only_fmt = matches!(name.as_str(), "Comma" | "Comma [0]" | "Currency" | "Currency [0]" | "Percent");
apply_style(s, p, move |st| {
if only_fmt {
st.num_fmt = style.num_fmt.clone();
} else {
let keep_fmt = st.num_fmt.clone();
*st = style.clone();
if style.num_fmt.as_str() == "General" {
st.num_fmt = keep_fmt;
}
}
})
}
// ---------------------------------------------------------------- sizes
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
let lines = body
.split(',')
.map(|part| {
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
let e = sh.rows.entry(r).or_default();
e.size = Some(h as f32);
e.hidden = h == 0.0;
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
let e = sh.cols.entry(c).or_default();
e.size = Some(w as f32);
e.hidden = w == 0.0;
}
}
Ok(Json::Null)
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
} else if c < 1.0 {
(c * 12.0).round()
} else {
(c * 7.0 + 5.0).round()
}
}
pub use gridcraft_model::points_to_chars;
mod cell_styles;
mod sizes;
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
w += match c {
'i' | 'l' | 'j' | '\'' | '.' | ',' | ':' | ';' | '!' | '|' => 0.28,
'f' | 't' | 'r' | 'I' | ' ' | '(' | ')' | '-' => 0.38,
'm' | 'w' | 'M' | 'W' => 0.85,
c if c.is_ascii_uppercase() => 0.65,
c if c.is_ascii_digit() => 0.56,
c if c.is_ascii() => 0.52,
_ => 1.0,
};
}
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
use cell_styles::cell_style;
pub use cell_styles::{CELL_STYLE_NAMES, builtin_cell_style};
pub use sizes::{approx_text_width, chars_to_points};
use sizes::{autofit_cols, autofit_rows, col_width, default_width, hide, row_height};
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
let Some(u) = used else { continue };
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
let mut max: f32 = 0.0;
for (cell, _) in sh.cells.iter_range(col) {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
widths.push((c, max.clamp(8.0, 1785.0)));
}
}
}
let sh = cx.sheet_mut(si)?;
for (c, w) in widths {
let e = sh.cols.entry(c).or_default();
e.size = Some(w.ceil());
e.hidden = false;
}
Ok(Json::Null)
})
}
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
for r in a..=b {
if let Some(e) = sh.rows.get_mut(&r) {
e.custom = false;
e.hidden = false;
}
}
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
// lines on both sides selected; we're forgiving).
let (a, b) = if !on && a == b { (a.saturating_sub(1), b + 1) } else { (a, b) };
for i in a..=b.min(a.saturating_add(1_048_575)) {
let map = if rows { &mut sh.rows } else { &mut sh.cols };
if on {
map.entry(i).or_default().hidden = true;
} else if let Some(e) = map.get_mut(&i) {
e.hidden = false;
if e.size == Some(0.0) {
e.size = None;
}
}
}
}
cx.structural = true; // SUBTOTAL(10x) depends on hidden rows
Ok(Json::Null)
})
}
#[cfg(test)]
mod tests {
-199
View File
@@ -1,199 +0,0 @@
use super::{
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
};
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
let _ = theme;
let mut s = Style::default();
let accent = |i: u8, tint: i16| Color::Theme(i, tint);
match name {
"Normal" => {}
"Good" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"Bad" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
"Neutral" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"Calculation" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Check Cell" => {
s.fill = Fill::solid(Color::rgb(0xA5, 0xA5, 0xA5));
s.font.color = Color::rgb(0xFF, 0xFF, 0xFF);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Double, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Explanatory Text" => {
s.font.italic = true;
s.font.color = Color::rgb(0x7F, 0x7F, 0x7F);
}
"Input" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xCC, 0x99));
s.font.color = Color::rgb(0x3F, 0x3F, 0x76);
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Linked Cell" => {
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::rgb(0xFF, 0x80, 0x01) };
}
"Note" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xFF, 0xCC));
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xB2, 0xB2, 0xB2) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Output" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0x3F, 0x3F, 0x3F);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Warning Text" => s.font.color = Color::rgb(0xFF, 0x00, 0x00),
"Title" => {
s.font.size = 18.0;
s.font.color = Color::Theme(3, 0);
s.font.name = theme.major_font.clone();
}
"Heading 1" => {
s.font.size = 15.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 0) };
}
"Heading 2" => {
s.font.size = 13.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 500) };
}
"Heading 3" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Medium, color: accent(4, 400) };
}
"Heading 4" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
}
"Total" => {
s.font.bold = true;
s.borders.top = BorderLine { style: BorderStyle::Thin, color: accent(4, 0) };
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: accent(4, 0) };
}
"Currency" => s.num_fmt = NumFmt::new(ACCOUNTING),
"Comma" => s.num_fmt = NumFmt::new(COMMA),
"Percent" => s.num_fmt = NumFmt::new("0%"),
"Currency [0]" => s.num_fmt = NumFmt::new("_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"),
"Comma [0]" => s.num_fmt = NumFmt::new("_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"),
n => {
// AccentN, 20%/40%/60% - AccentN
let (pct, rest) = match n.split_once("% - ") {
Some((p, r)) => (p.parse::<i16>().ok(), r),
None => (None, n),
};
let i: u8 = rest.strip_prefix("Accent")?.parse().ok()?;
if !(1..=6).contains(&i) {
return None;
}
match pct {
None => {
s.fill = Fill::solid(accent(3 + i, 0));
s.font.color = Color::Theme(0, 0);
}
Some(p) => {
s.fill = Fill::solid(accent(3 + i, (1000 - p * 10).clamp(0, 1000)));
s.font.color = if p >= 60 { Color::Theme(0, 0) } else { Color::Theme(1, 0) };
}
}
}
}
Some(s)
}
pub const CELL_STYLE_NAMES: &[&str] = &[
"Normal",
"Bad",
"Good",
"Neutral",
"Calculation",
"Check Cell",
"Explanatory Text",
"Input",
"Linked Cell",
"Note",
"Output",
"Warning Text",
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Title",
"Total",
"20% - Accent1",
"20% - Accent2",
"20% - Accent3",
"20% - Accent4",
"20% - Accent5",
"20% - Accent6",
"40% - Accent1",
"40% - Accent2",
"40% - Accent3",
"40% - Accent4",
"40% - Accent5",
"40% - Accent6",
"60% - Accent1",
"60% - Accent2",
"60% - Accent3",
"60% - Accent4",
"60% - Accent5",
"60% - Accent6",
"Accent1",
"Accent2",
"Accent3",
"Accent4",
"Accent5",
"Accent6",
"Comma",
"Comma [0]",
"Currency",
"Currency [0]",
"Percent",
];
pub(super) fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("Normal").to_string();
let d = s.doc()?;
let style =
d.wb.cell_styles
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map(|(_, s)| s.clone())
.or_else(|| builtin_cell_style(&name, &d.wb.theme));
let Some(style) = style else { return Err(bad("home.cellStyle", format!("unknown cell style `{name}`"))) };
// Number-format styles only change the number format.
let only_fmt = matches!(name.as_str(), "Comma" | "Comma [0]" | "Currency" | "Currency [0]" | "Percent");
apply_style(s, p, move |st| {
if only_fmt {
st.num_fmt = style.num_fmt.clone();
} else {
let keep_fmt = st.num_fmt.clone();
*st = style.clone();
if style.num_fmt.as_str() == "General" {
st.num_fmt = keep_fmt;
}
}
})
}
-245
View File
@@ -1,245 +0,0 @@
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
};
// ---------------------------------------------------------------- sizes
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
let lines = body
.split(',')
.map(|part| {
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet() {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
pub(super) fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
let e = sh.rows.entry(r).or_default();
e.size = Some(h as f32);
e.hidden = h == 0.0;
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
pub(super) fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
let e = sh.cols.entry(c).or_default();
e.size = Some(w as f32);
e.hidden = w == 0.0;
}
}
Ok(Json::Null)
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
} else if c < 1.0 {
(c * 12.0).round()
} else {
(c * 7.0 + 5.0).round()
}
}
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
w += match c {
'i' | 'l' | 'j' | '\'' | '.' | ',' | ':' | ';' | '!' | '|' => 0.28,
'f' | 't' | 'r' | 'I' | ' ' | '(' | ')' | '-' => 0.38,
'm' | 'w' | 'M' | 'W' => 0.85,
c if c.is_ascii_uppercase() => 0.65,
c if c.is_ascii_digit() => 0.56,
c if c.is_ascii() => 0.52,
_ => 1.0,
};
}
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
let Some(u) = used else { continue };
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
let mut max: f32 = 0.0;
for (cell, _) in sh.cells.iter_range(col) {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
widths.push((c, max.clamp(8.0, 1785.0)));
}
}
}
let sh = cx.sheet_mut(si)?;
for (c, w) in widths {
let e = sh.cols.entry(c).or_default();
e.size = Some(w.ceil());
e.hidden = false;
}
Ok(Json::Null)
})
}
pub(super) fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
for r in a..=b {
if let Some(e) = sh.rows.get_mut(&r) {
e.custom = false;
e.hidden = false;
}
}
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
pub(super) fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
pub(super) fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
// lines on both sides selected; we're forgiving).
let (a, b) = if !on && a == b { (a.saturating_sub(1), b + 1) } else { (a, b) };
for i in a..=b.min(a.saturating_add(1_048_575)) {
let map = if rows { &mut sh.rows } else { &mut sh.cols };
if on {
map.entry(i).or_default().hidden = true;
} else if let Some(e) = map.get_mut(&i) {
e.hidden = false;
if e.size == Some(0.0) {
e.size = None;
}
}
}
}
cx.structural = true; // SUBTOTAL(10x) depends on hidden rows
Ok(Json::Null)
})
}
+44 -260
View File
@@ -1,13 +1,10 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, stash, str_param,
target_range, target_ranges, target_sheet, u32_param,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -58,15 +55,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
create_from_selection
),
cmd!(
"formulas.pasteName",
"Paste Names…",
["Formulas", "Defined Names"],
Some("F3"),
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
has_doc,
paste_name
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
@@ -99,73 +87,42 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Whether a cell on the active sheet is empty.
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
sh.value(CellRef::new(row, col)).is_empty()
}
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
format!("={func}({})", RangeRef::new(from, to).a1())
}
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return Ok(None) };
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
}
/// One row: its total goes in its last cell when that is empty, else just after it.
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let row = sel.start.row;
let last_col = used.end.col.min(sel.end.col);
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
}
/// A block of rows: each column's total below it, and each row's total in an empty last column.
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let mut writes: Vec<(CellRef, String)> = Vec::new();
let last_col = used.end.col.min(sel.end.col);
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
Ok(writes)
}
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
// Single cell: guess the range above, else to the left.
let at = sel.start;
let numeric = |c: CellRef| sh.value(c).is_number();
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))) {
@@ -187,33 +144,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<Rang
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
range
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet();
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let text = match guessed_range(sh, at) {
let text = match range {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
@@ -501,7 +432,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
.iter()
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet());
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
@@ -579,65 +510,10 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet();
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
for n in shown(false) {
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
out.push((n.name.clone(), format!("={}", n.formula)));
}
}
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
out
}
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
if !bool_param(p, "list").unwrap_or(false) {
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
};
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
}
return Ok(json!({"names": listed}));
}
if names.is_empty() {
return Err(EngineError::Other("This workbook has no names to paste.".into()));
}
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet();
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
let c = CellRef::new(row, col);
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(c);
// The second column holds the refers-to text as a text value, with no formula.
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
cx.touch(sheet, c);
}
}
Ok(json!({"names": listed, "range": range.a1()}))
})
}
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at));
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
}
@@ -646,7 +522,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps);
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
deps.sort();
deps.dedup();
let v: Vec<Json> = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect();
@@ -659,7 +535,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Jso
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet();
let sheet = d.wb.active_sheet;
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
@@ -730,7 +606,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
let sheet = s.doc()?.wb.active_sheet;
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
let on = bool_param(p, "on").unwrap_or(!cur);
edit(s, |cx| {
@@ -788,19 +664,15 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
}
}
}
let circ: Vec<String> = d
.calc
.circular
.iter()
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
.collect();
let circ: Vec<String> =
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
Ok(json!({"errors": out, "circular": circ}))
}
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet();
let sheet = d.wb.active_sheet;
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
None => {
@@ -839,7 +711,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr);
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
@@ -1005,92 +877,4 @@ mod tests {
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
/// `Tax`, and a name of another sheet.
fn named_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
wb.names.push(name("TAX", Some(0), "0.25", false));
wb.names.push(name("Other", Some(1), "1", false));
s
}
fn text_at(s: &Session, cell: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
#[test]
fn paste_names_lists_the_names_the_active_sheet_can_use() {
let mut s = named_workbook();
let r = s.execute("formulas.pasteName", json!({})).unwrap();
assert_eq!(
r["names"],
json!([
{"name": "rate", "refersTo": "=0.07"},
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
{"name": "TAX", "refersTo": "=0.25"},
])
);
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
}
#[test]
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
let mut s = named_workbook();
s.take_ui_requests();
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
}
#[test]
fn paste_list_writes_each_name_and_what_it_refers_to() {
let mut s = named_workbook();
s.execute("selection.set", json!({"range": "D2"})).unwrap();
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
assert_eq!(r["range"], json!("D2:E4"));
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
for (cell, text) in rows {
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
}
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
s.execute("edit.undo", json!({})).unwrap();
for (cell, _) in rows {
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
}
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
}
#[test]
fn paste_list_needs_names_and_room() {
let mut s = named_workbook();
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
let mut empty = Session::new();
empty.new_workbook();
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
}
}
File diff suppressed because it is too large Load Diff
-423
View File
@@ -1,423 +0,0 @@
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
};
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
return target_ranges(s, p);
}
let d = s.doc()?;
if d.selection.ranges.len() > 1 {
return Ok(d.selection.ranges.clone());
}
Ok(vec![block(s, p)?])
}
// ---------------------------------------------------------------- charts
pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
match (t, sub) {
("column", "stacked") => ChartKind::ColumnStacked,
("column", "stacked100") => ChartKind::ColumnStacked100,
("bar", "stacked") => ChartKind::BarStacked,
("bar", "stacked100") => ChartKind::BarStacked100,
("bar", _) => ChartKind::BarClustered,
("line", "markers") => ChartKind::LineMarkers,
("line", "stacked") => ChartKind::LineStacked,
("line", _) => ChartKind::Line,
("pie", _) => ChartKind::Pie,
("doughnut", _) => ChartKind::Doughnut,
("area", "stacked") => ChartKind::AreaStacked,
("area", "stacked100") => ChartKind::AreaStacked100,
("area", _) => ChartKind::Area,
("scatter", "lines") => ChartKind::ScatterLines,
("scatter", _) => ChartKind::Scatter,
("bubble", _) => ChartKind::Bubble,
("radar", _) => ChartKind::Radar,
("histogram", _) => ChartKind::Histogram,
("waterfall", _) => ChartKind::Waterfall,
("funnel", _) => ChartKind::Funnel,
("treemap", _) => ChartKind::Treemap,
("sunburst", _) => ChartKind::Sunburst,
("boxWhisker", _) => ChartKind::BoxWhisker,
("stock", _) => ChartKind::Stock,
("combo", _) => ChartKind::Combo,
_ => ChartKind::ColumnClustered,
}
}
/// Builds series from a block: first row/column as names/categories when they're text.
pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series> {
let q = gridcraft_formula::quote_sheet(&sh.name);
let abs = |r: RangeRef| {
format!(
"{q}!${}${}:${}${}",
gridcraft_core::col_to_letters(r.start.col),
r.start.row + 1,
gridcraft_core::col_to_letters(r.end.col),
r.end.row + 1
)
};
let abs1 = |c: CellRef| format!("{q}!${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty();
let header_row = r.height() > 1
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
// A blank top-left cell above a header row marks the first column as the category labels even
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
// plotted as a series of its own (#287).
let blank_corner = header_row && sh.value(r.start).is_empty();
let label_col =
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
let mut out = Vec::new();
if !by_rows {
for c in data.start.col..=data.end.col {
out.push(Series {
name: header_row.then(|| abs1(CellRef::new(r.start.row, c))),
categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))),
values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
}
} else {
for row in data.start.row..=data.end.row {
out.push(Series {
name: label_col.then(|| abs1(CellRef::new(row, r.start.col))),
categories: header_row
.then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))),
values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
}
}
out
}
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
if areas.len() == 1 {
let by_rows = first.width() > first.height() + 1;
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
}
let valid_shape = first.width() == 1
&& first.height() > 1
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
if !valid_shape {
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
}
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
let mut series = Vec::with_capacity(areas.len() - 1);
for area in &areas[1..] {
let mut area_series = series_from_range(sh, *area, false);
let Some(mut item) = area_series.pop() else {
return Err(EngineError::Other("Select data for the chart first.".into()));
};
if item.name.is_none() {
return Err(bad("insert.chart", "multi-area series columns require headers"));
}
item.categories = category.clone();
series.push(item);
}
Ok((bounds, false, series))
}
/// A scatter plot of a block without category labels takes its first column (or row) as the X
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() {
let x = series.remove(0).values;
for se in series.iter_mut() {
se.categories = Some(x.clone());
}
}
}
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
_ => None,
})
.collect();
let points = ranges.first().map_or(0, RangeRef::count);
let (mut any_number, mut non_negative) = (false, true);
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
any_number = true;
non_negative &= n >= 0.0;
}
if series.is_empty() || !any_number {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let labelled = series.first().is_some_and(|x| x.categories.is_some());
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
const LINE: (&str, &str, &str) = ("line", "", "Line");
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
const AREA: (&str, &str, &str) = ("area", "", "Area");
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
let mut out = Vec::new();
if series.len() == 2 && !labelled && !by_rows {
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
} else if series.len() == 1 {
if points > 12 {
out.extend([LINE, COLUMN, AREA]);
} else {
out.extend([COLUMN, BAR]);
if non_negative && points >= 2 {
out.extend([PIE, DOUGHNUT]);
}
out.push(LINE_MARKERS);
}
} else if points > 12 {
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
} else {
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
}
Ok(out)
}
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
if t == "auto" {
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
t = first.0.into();
sub = first.1.into();
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let kind = chart_kind(&t, &sub);
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
scatter_x_from_first(kind, &mut series);
let id = d.wb.next_object_id();
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
} else {
Some("Chart Title".into())
}
});
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
by_rows,
pivot: None,
};
Ok((sheet, chart))
}
pub(super) fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, chart) = build_chart(s, p)?;
let id = chart.id;
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
Ok(json!({"chart": id}))
})
}
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart"))
}
pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let at = cell_param_on(s, p, "at", si)?;
let quartiles = p
.get("quartiles")
.map(|v| match v.as_str() {
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
})
.transpose()?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
if let Some(t) = str_param(p, "type") {
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
}
if let Some(method) = quartiles {
for series in &mut c.series {
series.quartiles = method;
}
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(str::to_string);
}
if let Some(l) = str_param(p, "legend") {
c.legend = match l {
"none" => LegendPos::None,
"top" => LegendPos::Top,
"left" => LegendPos::Left,
"right" => LegendPos::Right,
_ => LegendPos::Bottom,
};
}
if let Some(v) = bool_param(p, "dataLabels") {
c.data_labels = v;
}
if let Some(v) = bool_param(p, "gridlines") {
c.gridlines = v;
}
if let Some(v) = u32_param(p, "style") {
c.style = v.clamp(1, 16);
}
if let Some(t) = p.get("xTitle") {
c.x_title = t.as_str().map(str::to_string);
}
if let Some(t) = p.get("yTitle") {
c.y_title = t.as_str().map(str::to_string);
}
if let Some(at) = at {
c.anchor.cell = at;
}
if let Some(w) = f64_param(p, "width") {
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
}
if let Some(h) = f64_param(p, "height") {
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
}
if let Some(v) = f64_param(p, "dx") {
c.anchor.dx = v as f32;
}
if let Some(v) = f64_param(p, "dy") {
c.anchor.dy = v as f32;
}
Ok(Json::Null)
})
}
pub(super) fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
// Charts read from a file have no source range: take the block their series span.
let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) };
let (sheet_name, body) = split_sheet(&src);
let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) };
let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
// Whole-column/row references (common in files) span a million cells: clip the block's
// far edges to the cells in use, then refuse anything still too large to chart.
if let Some(used) = ssh.used_range() {
r.end.row = r.end.row.min(used.end.row).max(r.start.row);
r.end.col = r.end.col.min(used.end.col).max(r.start.col);
}
if r.count() > MAX_CHART_CELLS {
return Err(bad("chart.switchRowColumn", "range too large for a chart"));
}
let by_rows = !by_rows;
let series = series_from_range(ssh, r, by_rows);
if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
c.source.get_or_insert(src);
c.by_rows = by_rows;
c.series = series;
scatter_x_from_first(c.kind, &mut c.series);
}
Ok(Json::Null)
})
}
/// The block a chart's series come from, and whether each series is a row of it: the bounding
/// range of the series names, categories and values, when they're all references to one sheet.
fn series_block(series: &[Series]) -> Option<(String, bool)> {
let mut sheet: Option<String> = None;
let mut bounds: Option<RangeRef> = None;
let mut rows = 0;
for s in series {
for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() {
let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None };
let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None };
if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) {
return None;
}
let range = r.range();
if i == 0 && range.height() == 1 && range.width() > 1 {
rows += 1;
}
bounds = Some(bounds.map_or(range, |b| b.union(&range)));
}
}
Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len()))
}
pub(super) fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.charts.remove(ci);
Ok(Json::Null)
})
}
-342
View File
@@ -1,342 +0,0 @@
use super::{
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
};
// ---------------------------------------------------------------- conditional formatting
fn preset_style(name: &str) -> Style {
let mut s = Style::default();
match name {
"redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06),
"yellowFill" | "yellowFillDarkYellowText" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"greenFill" | "greenFillDarkGreenText" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)),
"redBorder" => {
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
_ => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
}
s
}
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
fn is_string_literal(v: &str) -> bool {
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
}
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
fn cf_style_json(st: &Json) -> Result<Json> {
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
let err = |m: String| bad("home.conditionalFormat", m);
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
let mut st = obj.clone();
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
}
// A colour written the way the other formatting commands take it, as the model stores it.
let color = |v: &mut Json, what: &str| -> Result<()> {
if v.is_string() || v.get("theme").is_some() {
let c = crate::cmd::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(())
};
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
&& let Some(c) = f.get_mut("color")
{
color(c, "font.color")?;
}
if let Some(fill) = st.get_mut("fill") {
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
if fill.is_string() && crate::cmd::format::color_param(Some(fill)) == Some(Color::Auto) {
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
} else if fill.is_string() || fill.get("theme").is_some() {
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
}
if let Some(f) = fill.as_object_mut() {
for k in ["fg", "bg"] {
if let Some(c) = f.get_mut(k) {
color(c, &format!("fill.{k}"))?;
}
}
// A colour with no pattern is a solid fill, not an invisible one.
if f.contains_key("fg") && !f.contains_key("pattern") {
f.insert("pattern".into(), json!("Solid"));
}
}
}
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
for (side, line) in b.iter_mut() {
if let Some(c) = line.get_mut("color") {
color(c, &format!("borders.{side}.color"))?;
}
}
}
Ok(Json::Object(st))
}
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
// only parses after several strips and would still be saved unparseable. (#1999 review)
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
}
Ok(())
}
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
check_cf_formula("home.conditionalFormat", what, f)?;
}
Ok(())
}
fn check_cf_rule(rule: &CfRule) -> Result<()> {
const CMD: &str = "home.conditionalFormat";
match rule {
CfRule::CellIs { a, b, .. } => {
check_cf_formula(CMD, "comparison value", a)?;
if let Some(b) = b {
check_cf_formula(CMD, "second comparison value", b)?;
}
}
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
CfRule::DataBar { min, max, .. } => {
check_cf_value("data bar minimum", min)?;
check_cf_value("data bar maximum", max)?;
}
CfRule::ColorScale { stops } => {
for (kind, _) in stops {
check_cf_value("color scale threshold", kind)?;
}
}
_ => {}
}
Ok(())
}
pub(super) fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
let Some(rule) = p.get("rule") else {
s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({})));
return ok();
};
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
let style = Box::new(match rule.get("style") {
Some(st) if !st.is_null() => {
let st = cf_style_json(st)?;
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
serde_json::from_value(crate::cmd::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
}
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
});
let kind = g("type").unwrap_or_else(|| "cellIs".into());
let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind {
match v {
Some(Json::Object(o)) => {
let t = o.get("type").and_then(Json::as_str).unwrap_or("num");
let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0);
match t {
"min" => CfValueKind::Min,
"max" => CfValueKind::Max,
"percent" => CfValueKind::Percent(val),
"percentile" => CfValueKind::Percentile(val),
"formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()),
_ => CfValueKind::Number(val),
}
}
_ => default,
}
};
let rule = match kind.as_str() {
"cellIs" => {
// Excel's file and VBA names (`lessThan`, …) are accepted too.
let op = match g("operator").as_deref().unwrap_or("greater") {
"between" => CfOperator::Between,
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
"greater" | "greaterThan" => CfOperator::Greater,
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
};
let a = rule.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(crate::cmd::edit::json_to_input);
let quote = |v: String| {
// A number, a formula, or text already written as a string literal (`"Closed"`, as
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
v.trim_start_matches('=').to_string()
} else if is_string_literal(&v) {
v
} else {
format!("\"{}\"", v.replace('"', "\"\""))
}
};
CfRule::CellIs { op, a: quote(a), b: b.map(quote), style }
}
"expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style },
"containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style },
"notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style },
"beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style },
"endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style },
"blanks" => CfRule::Blanks { style },
"noBlanks" => CfRule::NoBlanks { style },
"errors" => CfRule::Errors { style },
"noErrors" => CfRule::NoErrors { style },
"duplicate" => CfRule::Duplicate { style },
"unique" => CfRule::Unique { style },
"top10" => CfRule::Top10 {
bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false),
percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false),
rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32,
style,
},
"aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style },
"timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style },
"colorScale" => {
let cols: Vec<Color> = rule
.get("colors")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| crate::cmd::format::color_param(Some(c))).collect())
.unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]);
let stops = match cols.len() {
2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])],
_ => vec![
(CfValueKind::Min, cols.first().copied().unwrap_or_default()),
(CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()),
(CfValueKind::Max, cols.get(2).copied().unwrap_or_default()),
],
};
CfRule::ColorScale { stops }
}
"dataBar" => CfRule::DataBar {
min: value_kind(rule.get("min"), CfValueKind::Min),
max: value_kind(rule.get("max"), CfValueKind::Max),
color: crate::cmd::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)),
gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true),
show_value: true,
},
"iconSet" => CfRule::IconSet {
set: g("set").unwrap_or_else(|| "3TrafficLights1".into()),
thresholds: vec![],
reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false),
show_value: true,
},
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
};
check_cf_rule(&rule)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
// New rules take precedence over older ones.
for cf in sh.cond_formats.iter_mut() {
cf.priority += 1;
}
sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false });
let _ = priority;
Ok(json!({"rules": sh.cond_formats.len()}))
})
}
pub(super) fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let whole = bool_param(p, "sheet").unwrap_or(false);
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if whole {
sh.cond_formats.clear();
} else {
for cf in sh.cond_formats.iter_mut() {
cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r)));
}
sh.cond_formats.retain(|c| !c.ranges.is_empty());
}
Ok(Json::Null)
})
}
pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() {
let list: Vec<Json> = s
.doc()?
.wb
.sheet(sheet)
.map(|sh| {
sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), "rule": c.rule})).collect()
})
.unwrap_or_default();
return Ok(json!({"rules": list}));
}
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
// The count is taken again after each step: a delete shortens the list.
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
&& i < sh.cond_formats.len()
{
sh.cond_formats.remove(i);
}
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
&& i > 0
&& i < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i - 1);
}
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
&& i + 1 < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i + 1);
}
for (k, cf) in sh.cond_formats.iter_mut().enumerate() {
cf.priority = k as u32 + 1;
}
Ok(Json::Null)
})
}
pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("check").to_string();
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
fill: color,
line: Color::Auto,
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
-160
View File
@@ -1,160 +0,0 @@
use super::{
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
split_sheet, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- links & comments
pub(super) fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
if bool_param(p, "remove").unwrap_or(false) {
return edit(s, |cx| {
cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
Ok(Json::Null)
});
}
let Some(target) = str_param(p, "target").map(str::to_string) else {
s.ui_requests.push(crate::UiRequest::Dialog("insertLink".into(), json!({})));
return ok();
};
let text = str_param(p, "text").map(str::to_string);
let tooltip = str_param(p, "tooltip").map(str::to_string);
let external = links_outside(&s.doc()?.wb, sheet, &target);
edit(s, |cx| {
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
st.font.color = Color::Theme(10, 0);
st.font.underline = Underline::Single;
});
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
} else {
sh.set_style(at, link_style);
}
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
cx.touch(sheet, at);
Ok(Json::Null)
})
}
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
/// dialog; the interactive click asks for confirmation first, in the UI.
pub(super) fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
Some(t) => t.to_string(),
None => {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
};
h.target.clone()
}
};
match crate::links::classify(&target) {
crate::links::LinkKind::Internal(r) => crate::cmd::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
crate::links::LinkKind::External(u) => {
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
Ok(json!({"opened": u}))
}
crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
}
}
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
/// Page") is outside the workbook and saved as a relationship.
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
let t = target.trim();
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
return false;
}
let (on, body) = split_sheet(t);
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
let on_sheet = on.is_none_or(|name| {
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
});
!(on_sheet && RangeRef::parse(body).is_some())
}
pub(super) fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let named = str_param(p, "author").map(str::to_string);
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
let comments = &mut cx.sheet_mut(sheet)?.comments;
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
c.text = text.clone();
if let Some(author) = named.clone() {
c.author = author;
}
return Ok(json!({"cell": at.a1()}));
}
comments.insert(
at,
Comment {
author: author.clone(),
text: text.clone(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
Ok(json!({"cell": at.a1()}))
})
}
pub(super) fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?;
c.replies.push((author.clone(), text.clone()));
Ok(Json::Null)
})
}
pub(super) fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let v = bool_param(p, "resolved").unwrap_or(true);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.resolved = v;
}
Ok(Json::Null)
})
}
pub(super) fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c)));
Ok(Json::Null)
})
}
pub(super) fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.visible = !c.visible;
}
Ok(Json::Null)
})
}
-267
View File
@@ -1,267 +0,0 @@
use super::{
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
};
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let kind = match str_param(p, "type") {
Some("column") => SparklineKind::Column,
Some("winLoss") => SparklineKind::WinLoss,
_ => SparklineKind::Line,
};
let markers = bool_param(p, "markers").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.sparklines.retain(|x| x.cell != loc);
sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers });
Ok(Json::Null)
})
}
pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let placement = str_param(p, "placement").unwrap_or("overCells");
if !matches!(placement, "cell" | "overCells") {
return Err(bad("insert.picture", "placement must be cell or overCells"));
}
let data = if let Some(b) = str_param(p, "base64") {
crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement})));
return ok();
};
if placement == "cell" {
let mime = validate_cell_picture(&data)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let at = match str_param(p, "at") {
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
None => d.selection.active,
};
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
let style = sh.style_id(at);
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
}
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
return edit(s, |cx| {
cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture));
cx.touch(sheet, at);
Ok(json!({"cell": at.a1(), "placement": "cell"}))
});
}
if data.len() > 64 * 1024 * 1024 {
return Err(bad("insert.picture", "image larger than 64 MB"));
}
let mime = str_param(p, "mime")
.map(str::to_string)
.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()?;
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);
let img = Image {
id,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
},
data: data.into(),
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.images.push(img);
Ok(json!({"image": id}))
})
}
/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode.
fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
if data.len() > 16 * 1024 * 1024 {
return Err(bad("insert.picture", "cell pictures must be at most 16 MiB"));
}
let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?;
let mime = match format {
image::ImageFormat::Png => "image/png",
image::ImageFormat::Jpeg => "image/jpeg",
_ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")),
};
let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format)
.into_dimensions()
.map_err(|_| bad("insert.picture", "invalid image dimensions"))?;
if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 {
return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels"));
}
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?;
Ok(mime)
}
pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
"ellipse" | "oval" => ShapeKind::Ellipse,
"triangle" => ShapeKind::Triangle,
"line" => ShapeKind::Line,
"arrow" => ShapeKind::Arrow,
"textBox" => ShapeKind::TextBox,
_ => ShapeKind::Rectangle,
};
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let fill = crate::cmd::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox {
Color::rgb(255, 255, 255)
} else {
Color::Theme(4, 0)
});
let line = crate::cmd::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
Color::rgb(0x80, 0x80, 0x80)
} else {
Color::Theme(4, -250)
});
let shape = Shape {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(144.0) as f32,
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
fill,
line,
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
/// A text box holds at most as many characters as a cell.
const SHAPE_TEXT_MAX_CHARS: usize = 32_767;
pub(super) fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
let id =
p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?;
let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?;
if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() {
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
if shape.kind != ShapeKind::TextBox {
return Err(bad("shape.setText", "the shape is not a text box"));
}
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
}
if shape.text == text {
return ok();
}
edit(s, |cx| {
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
shape.text = text.into();
Ok(Json::Null)
})
}
pub(super) fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
"slicer" => sh.slicers.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
})
}
pub(super) fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet();
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
if let Some(at) = at {
a.cell = at;
}
if let Some(v) = f64_param(p, "dx") {
a.dx = v as f32;
}
if let Some(v) = f64_param(p, "dy") {
a.dy = v as f32;
}
if let Some(v) = f64_param(p, "width") {
a.width = v.clamp(1.0, 10000.0) as f32;
}
if let Some(v) = f64_param(p, "height") {
a.height = v.clamp(0.0, 10000.0) as f32;
}
Ok(Json::Null)
})
}
/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
/// when the sheet is sorted or edited structurally.
pub(super) fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
}
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
};
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
a.mode = mode;
Ok(json!({"mode": anchor_mode_name(mode)}))
})
}
-334
View File
@@ -1,334 +0,0 @@
use super::{
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
};
// ---------------------------------------------------------------- tables
pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let r = block(s, p)?;
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let arrays = |sh: &gridcraft_model::Sheet| {
sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r)))
};
if d.wb.sheet(sheet).is_some_and(arrays) {
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
}
if r.count() > 50_000_000 {
return Err(bad("insert.table", "range too large"));
}
let header = bool_param(p, "header").unwrap_or_else(|| {
let sh = d.wb.sheet(sheet);
sh.is_some_and(|sh| (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && r.height() > 1)
});
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
// Without a header row, the data moves down a row to make room for one: never a table with it.
let moves_a_table = |sh: &gridcraft_model::Sheet| {
sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col)
};
if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) {
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
}
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
let id = d.wb.next_object_id();
edit(s, |cx| {
// Without a header row, insert one with generic names.
let r = if header {
r
} else {
let sh = cx.sheet_mut(sheet)?;
// The header row pushes the data down one row: never off the end of the sheet.
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
}
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
// reference (on any sheet) at the cells that moved.
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
}
cx.structural = true;
RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col))
};
let sh = cx.sheet_mut(sheet)?;
let mut names: Vec<String> = Vec::new();
// Lower-case names taken, and the next suffix for each header, so that thousands of equal
// headers don't take cubic time.
let mut taken = std::collections::HashSet::new();
let mut next_suffix = std::collections::HashMap::new();
for c in r.start.col..=r.end.col {
let mut n = sh.value(CellRef::new(r.start.row, c)).display();
if n.is_empty() {
n = format!("Column{}", c - r.start.col + 1);
}
let base = n.clone();
let key = base.to_ascii_lowercase();
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
while taken.contains(&n.to_ascii_lowercase()) {
n = format!("{base}{k}");
k += 1;
}
next_suffix.insert(key, k);
taken.insert(n.to_ascii_lowercase());
// Headers are text.
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str()));
names.push(n);
}
sh.tables.push(Table {
id,
name: name.clone(),
range: r,
header_row: true,
totals_row: false,
columns: names
.into_iter()
.map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
.collect(),
style,
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
table_type: None,
connection_id: None,
});
cx.structural = true;
Ok(json!({"table": name, "range": r.a1()}))
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
pub(super) fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
}
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let ti = sh
.tables
.iter()
.position(|t| t.range.contains(d.selection.active))
.ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?;
Ok((sheet, ti))
}
pub(super) fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let on = bool_param(p, "on");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) };
match which {
"totals" => {
let v = on.unwrap_or(!t.totals_row);
if v != t.totals_row {
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
}
t.totals_row = v;
if v {
t.range.end.row += 1;
if let Some(first) = t.columns.first_mut() {
first.totals_label = Some("Total".into());
}
if t.columns.len() > 1
&& let Some(last) = t.columns.last_mut()
&& last.totals == TotalsFn::None
{
last.totals = TotalsFn::Sum;
}
} else {
let row = t.range.end.row;
t.range.end.row = t.range.end.row.saturating_sub(1);
let (c0, c1) = (t.range.start.col, t.range.end.col);
for c in c0..=c1 {
sh.remove_cell(CellRef::new(row, c));
}
}
write_totals(sh, ti);
cx.structural = true;
}
}
"bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows),
"bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols),
"firstCol" => t.first_col = on.unwrap_or(!t.first_col),
"lastCol" => t.last_col = on.unwrap_or(!t.last_col),
"header" => {
t.filter_button = on.unwrap_or(!t.filter_button);
if !t.filter_button {
t.filter.clear();
}
}
_ => {}
}
Ok(Json::Null)
})
}
/// Writes the totals row cells (labels and SUBTOTAL formulas).
fn write_totals(sh: &mut Sheet, ti: usize) {
let Some(t) = sh.tables.get(ti).cloned() else { return };
if !t.totals_row {
return;
}
let row = t.range.end.row;
for (i, col) in t.columns.iter().enumerate() {
let c = CellRef::new(row, t.range.start.col + i as u32);
if let Some(code) = col.totals.subtotal_code() {
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
} else if let Some(l) = &col.totals_label {
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
} else {
sh.remove_cell(c);
}
}
}
pub(super) fn table_total_fn(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string();
let f = match str_param(p, "function").unwrap_or("sum") {
"average" => TotalsFn::Average,
"count" => TotalsFn::Count,
"countNums" => TotalsFn::CountNums,
"max" => TotalsFn::Max,
"min" => TotalsFn::Min,
"stdDev" => TotalsFn::StdDev,
"var" => TotalsFn::Var,
"none" => TotalsFn::None,
_ => TotalsFn::Sum,
};
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
if let Some(t) = sh.tables.get_mut(ti)
&& let Some(i) = t.column_index(&col)
&& let Some(c) = t.columns.get_mut(i)
{
c.totals = f;
}
write_totals(sh, ti);
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
edit(s, |cx| {
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
t.style = style.clone();
}
Ok(Json::Null)
})
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
/// read as an operator inside a structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
name.chars().count() <= 255
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& !crate::cmd::formulas::is_r1c1(name)
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
pub(super) fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
let wb = &s.doc()?.wb;
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
edit(s, |cx| {
let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default();
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
t.name = name.clone();
}
// Structured references follow the rename.
for i in 0..cx.wb.sheets.len() {
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
let shadowed = cx.wb.name(&old, i).is_some();
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
for (c, f) in keys {
let Some(e) = f.expr() else { continue };
let ne = e.map(&mut |x| match x {
gridcraft_formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => {
st.table = name.clone();
gridcraft_formula::Expr::Struct(st)
}
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
o => o,
});
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
}
}
}
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn table_convert(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
edit(s, |cx| {
// Apply the table look as plain formatting, then drop the table.
let wb = &mut cx.wb;
let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) };
let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::tables::table_cell_style(wb, si, &t, c))).collect();
for (c, st) in looks {
let id = wb.styles.intern(st);
if let Some(sh) = wb.sheet_mut(si) {
sh.set_style(c, id);
}
}
if let Some(sh) = wb.sheet_mut(si) {
sh.tables.remove(ti);
}
cx.structural = true;
Ok(Json::Null)
})
}
+2 -2
View File
@@ -3,7 +3,7 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param_on, cmd, has_doc, str_param, target_range, target_sheet};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -64,7 +64,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet(),
"activeSheet": wb.active_sheet,
"sheets": sheets,
"selection": d.selection.a1(),
"activeCell": d.selection.active.a1(),
@@ -165,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
@@ -205,6 +205,7 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
@@ -295,7 +296,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
{
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
}
Ok(d.wb.active_sheet())
Ok(d.wb.active_sheet)
}
pub(crate) use gridcraft_core::split_sheet;
@@ -304,7 +305,7 @@ pub(crate) use gridcraft_core::split_sheet;
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet())
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
@@ -348,15 +349,11 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
})
}
fn wall_clock() -> std::time::Duration {
std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
// `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch
// reads the wall clock without the (disallowed) `now`.
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
#[allow(clippy::disallowed_methods)]
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let r = f(&mut ctx)?;
@@ -371,7 +368,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
}
if structural {
d.calc.recalc_all(&mut wb);
@@ -379,7 +376,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
d.calc.cells_changed(&mut wb, &changed);
}
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", since(t0));
eprintln!("commit: + recalc {:?}", t0.elapsed());
}
fit_rows.sort_unstable();
fit_rows.dedup();
@@ -387,7 +384,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
auto_row_height(&mut wb, si, row);
}
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", since(t0));
eprintln!("commit: + rows {:?}", t0.elapsed());
}
d.wb = std::sync::Arc::new(wb);
d.selection = sel;
@@ -555,15 +552,9 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
return;
}
let mut need: f32 = default;
// The cell loop must not rescan every merge per cell: the merges touching this row are
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
// a sheet with many merges and many saved columns would otherwise scan all of them per
// cell, rows × merges × columns).
let row_merges: Vec<gridcraft_core::RangeRef> =
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
continue;
}
let st = wb.styles.get(cell.style);
@@ -596,7 +587,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
impl Ctx<'_> {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet()
self.wb.active_sheet
}
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More