Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5fb5136f7 | |||
| f162240fcf | |||
| 8033af3f03 | |||
| 14b6964db0 | |||
| 61201a1173 | |||
| fc45b7f6d8 | |||
| 9cb931bac9 | |||
| 85bfcdfcfd | |||
| 9fc4108899 | |||
| b3acae6b77 | |||
| 587d879f13 | |||
| 3926d21aa9 | |||
| 3b19cb80e6 | |||
| 63e60de76c | |||
| a34fe4a7af | |||
| 0a54876e2c | |||
| 61e3dd99f7 | |||
| 69b5f78e86 | |||
| dcd469d12e | |||
| f170b89a92 | |||
| 80c2efe0f1 | |||
| 74d57ea9fd | |||
| 47b2cfbcd0 | |||
| a96f19f02b | |||
| e2bcb712fd | |||
| 936c6a055a | |||
| 5432419cd5 | |||
| b13ea3e2f3 | |||
| e6773dac3c | |||
| 1841658978 | |||
| f38b17fb67 | |||
| dbc04ddae0 | |||
| 88df1670ba | |||
| 83d6a6fbe7 | |||
| 009364e72e | |||
| 1532349227 | |||
| 312b4c056e | |||
| 8de8bc187a | |||
| c882d169ff | |||
| aad58d4c75 | |||
| c9f03cc36a | |||
| 70d857a699 | |||
| 3936f3b79c | |||
| 1a6c864235 | |||
| 478f01d236 | |||
| 7a2361de7f | |||
| 9e9f9aec84 | |||
| 8e7e247589 | |||
| d2a691aea8 | |||
| 7ba871c6f2 | |||
| cbd9bb51c6 | |||
| 7591fca086 | |||
| cd5f3f0a71 | |||
| 659a4d5bd3 | |||
| 828efab385 | |||
| 8ea2342703 | |||
| 53690bace0 | |||
| 9ce910f87f | |||
| fdb74c27dd | |||
| eecfa4074d | |||
| 13afa8c773 | |||
| f7bc97b378 | |||
| ba830cacf8 | |||
| 737999f182 | |||
| 95b3641725 | |||
| f11d74ee7c | |||
| 82cf8a6bb3 | |||
| 1d1d27f9a4 | |||
| 993c108660 | |||
| e90ebbbbe2 | |||
| eba3957a21 | |||
| b415d2e835 | |||
| 2f22346807 | |||
| 9b59966cc9 | |||
| d0ff4265c7 | |||
| 56f784473d |
@@ -0,0 +1,48 @@
|
||||
# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land
|
||||
# a red tree that only the next agent session discovers.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Linux build dependencies (winit, wgpu, rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
run: cargo xtask ci
|
||||
@@ -35,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -25,7 +25,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command).
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
@@ -25,7 +25,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command).
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
Generated
+1735
-32
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -35,10 +35,13 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
|
||||
aes = { version = "0.9", default-features = false }
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
|
||||
+7
-2
@@ -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-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **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.
|
||||
@@ -32,7 +32,7 @@ This page is the summary. The detail is in:
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile |
|
||||
| Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) |
|
||||
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` and encrypted import 28–42 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
|
||||
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
|
||||
|
||||
Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and
|
||||
calibration](docs/target-app-parity.md#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy
|
||||
@@ -132,6 +132,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
||||
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
||||
| 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) |
|
||||
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
|
||||
@@ -175,6 +178,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row |
|
||||
|
||||
@@ -18,3 +18,6 @@ gridcraft-engine = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# `check`: validating the packages GridCraft writes (content types, relationships).
|
||||
zip = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
//! gridcraft-cli convert <in> <out> [--sheet NAME]
|
||||
//! gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
|
||||
//! gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
|
||||
//! gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
|
||||
//! gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet]
|
||||
//! gridcraft-cli commands [--json] [--search X]
|
||||
//! gridcraft-cli functions [--json] [--search X] [--category C]
|
||||
@@ -28,6 +29,8 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
mod check;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -36,6 +39,8 @@ const USAGE: &str = "usage:
|
||||
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
|
||||
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
|
||||
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
|
||||
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
|
||||
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
|
||||
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
|
||||
[--out FILE] [--print RANGE] [--quiet]
|
||||
gridcraft-cli commands [--json] [--search X]
|
||||
@@ -71,6 +76,7 @@ fn main() -> ExitCode {
|
||||
Some("eval") => eval(rest),
|
||||
Some("cat") => cat(rest),
|
||||
Some("run") => run(rest),
|
||||
Some("check") => return check::main(rest),
|
||||
Some("commands") => commands(rest),
|
||||
Some("functions") => functions(rest),
|
||||
Some("mcp") => mcp(rest),
|
||||
|
||||
@@ -150,3 +150,27 @@ fn mcp_over_stdio() {
|
||||
assert_eq!(lines[0]["result"]["serverInfo"]["name"], "gridcraft");
|
||||
assert!(lines[1]["result"]["content"][0]["text"].as_str().unwrap().contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_exit_codes_and_json() {
|
||||
let dir = tmpdir("check");
|
||||
let book = dir.join("calc.xlsx");
|
||||
let book_s = book.to_str().unwrap();
|
||||
ok(&["run", "--cmd", r#"cell.set={"cell":"A1","input":"2"}"#, "--cmd", r#"cell.set={"cell":"B1","input":"=A1*3"}"#, "--out", book_s, "--quiet"]);
|
||||
// GridCraft's own file: its cached values are its own results, and it round-trips.
|
||||
let o = run(&["check", dir.to_str().unwrap()]);
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(o.status.code(), Some(0), "{out}");
|
||||
assert!(out.contains("1 checked: 1 matched, 0 mismatched"), "{out}");
|
||||
let v: serde_json::Value = serde_json::from_str(&ok(&["check", book_s, "--json"])).unwrap();
|
||||
assert_eq!(v["schema"], "gridcraft-check/1");
|
||||
assert_eq!(v["totals"]["matched"], 1);
|
||||
// A file that can't be opened is reported, not fatal, and fails the check.
|
||||
let o = run(&["check", book_s, "/no/such/file.xlsx"]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("1 failed to open"));
|
||||
// Usage errors exit 2.
|
||||
assert_eq!(run(&["check"]).status.code(), Some(2));
|
||||
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
|
||||
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ image = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
|
||||
+15
-68
@@ -16,6 +16,7 @@ mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod print;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -39,6 +40,10 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.2, &mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(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);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -53,6 +58,7 @@ impl eframe::App for App {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(&self.0);
|
||||
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
@@ -180,12 +186,8 @@ fn services() -> Services {
|
||||
dialog.save_file().and_then(|p| p.to_str().map(str::to_string))
|
||||
})),
|
||||
open_url: Some(Box::new(|url: &str| open_with_system(url))),
|
||||
// File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print
|
||||
// command reaches the printer dialog (GridCraft has no printer driver of its own).
|
||||
print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) {
|
||||
Ok(path) => open_with_system(&path.to_string_lossy()),
|
||||
Err(e) => log::warn!("print: {e}"),
|
||||
})),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -204,32 +206,6 @@ fn open_with_system(target: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the
|
||||
/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively
|
||||
/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable
|
||||
/// only by the user.
|
||||
fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
|
||||
use std::io::Write;
|
||||
static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
for _ in 0..64 {
|
||||
let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
|
||||
let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id()));
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(bytes)?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name"))
|
||||
}
|
||||
|
||||
fn icon() -> Option<egui::IconData> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
|
||||
@@ -304,19 +280,20 @@ fn main() -> eframe::Result<()> {
|
||||
{
|
||||
log::warn!("sample {s}: {e}");
|
||||
}
|
||||
for f in &files {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
if session.documents().is_empty() {
|
||||
session.new_workbook();
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.watch_desktop_theme();
|
||||
app.after_engine(); // Show warnings from files opened on the command line.
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
|
||||
// opens them: a password-protected one asks for its password, and errors are shown, not
|
||||
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
}
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("GridCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
@@ -392,33 +369,3 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod print_tests {
|
||||
#[test]
|
||||
fn print_pdfs_get_fresh_names_in_the_given_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap();
|
||||
let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf"));
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b");
|
||||
assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err());
|
||||
// A file (or symlink) already at the next name is skipped, never overwritten.
|
||||
let pid = std::process::id();
|
||||
let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect();
|
||||
for p in &taken {
|
||||
std::fs::write(p, b"keep").unwrap();
|
||||
}
|
||||
let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap();
|
||||
assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep"));
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
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, default: printer.default }).collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
})
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
|
||||
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(|poison| poison.into_inner()));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit() -> Submit {
|
||||
Box::new(|bytes, settings: Settings| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
|
||||
let job = craft_print::Job {
|
||||
printer: Some(settings.printer),
|
||||
copies: settings.copies.clamp(1, 999),
|
||||
duplex: settings.duplex.map(|value| match value {
|
||||
Duplex::LongEdge => craft_print::Duplex::LongEdge,
|
||||
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
|
||||
Duplex::Off => craft_print::Duplex::Off,
|
||||
}),
|
||||
grayscale: settings.grayscale,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: "GridCraft document".into(),
|
||||
..Default::default()
|
||||
};
|
||||
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(|poison| poison.into_inner());
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
receiver
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shutdown_waits_for_pending_submission_workers() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let (completed, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
completed.send(()).unwrap();
|
||||
});
|
||||
JOBS.lock().unwrap().push(worker);
|
||||
let shutdown = std::thread::spawn(finish_pending_jobs);
|
||||
assert!(completion.try_recv().is_err(), "the print worker is still pending");
|
||||
release.send(()).unwrap();
|
||||
shutdown.join().unwrap();
|
||||
completion.recv().unwrap();
|
||||
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
|
||||
}
|
||||
}
|
||||
@@ -54,3 +54,10 @@ sync_github_name = true
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.shrshk]
|
||||
real_name = "Shirish Veerabattini"
|
||||
sync_github_name = true
|
||||
[people.jsymes21-byte]
|
||||
real_name = "James Symes"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -352,6 +352,9 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn unary(&mut self, op: UnOp, x: &Expr) -> Ev {
|
||||
if let UnOp::Trim(mode) = op {
|
||||
return self.trim_ref(mode, x);
|
||||
}
|
||||
if op == UnOp::Spill {
|
||||
return match self.eval(x) {
|
||||
Ev::R(areas) => match areas.as_slice() {
|
||||
@@ -386,7 +389,7 @@ impl<'h> Evaluator<'h> {
|
||||
UnOp::Neg => Value::number(-n),
|
||||
UnOp::Plus => Value::number(n),
|
||||
UnOp::Percent => Value::number(n / 100.0),
|
||||
UnOp::At | UnOp::Spill => Value::number(n),
|
||||
UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n),
|
||||
},
|
||||
op == UnOp::Plus,
|
||||
))
|
||||
@@ -1308,6 +1311,9 @@ impl<'h> Evaluator<'h> {
|
||||
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);
|
||||
let skip_nested = options <= 3;
|
||||
let mut values = Vec::new();
|
||||
@@ -1325,7 +1331,8 @@ impl<'h> Evaluator<'h> {
|
||||
let (hidden, nested) = {
|
||||
let wb = self.host.workbook();
|
||||
let s = wb.sheet(a.sheet);
|
||||
let hidden = skip_hidden && s.is_some_and(|s| s.is_row_hidden(c.row));
|
||||
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();
|
||||
|
||||
+154
-45
@@ -6,7 +6,9 @@
|
||||
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
|
||||
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
|
||||
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
|
||||
//! calculation is on.
|
||||
//! calculation is on: then a formula read while it is being evaluated gives its previous value,
|
||||
//! and the formulas that read one are evaluated again until the loop settles
|
||||
//! (`PassHost::iterate_loops`).
|
||||
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
@@ -58,6 +60,10 @@ const WIDE: u32 = 64;
|
||||
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
|
||||
/// `a..=b`?" in O(log n + k).
|
||||
///
|
||||
@@ -389,6 +395,44 @@ struct PassHost<'a> {
|
||||
cycle: bool,
|
||||
cycles: Vec<Key>,
|
||||
depth: usize,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
/// giving `#CIRC!`.
|
||||
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).
|
||||
members: HashSet<Key>,
|
||||
/// Results that read a previous value, directly or through other formulas.
|
||||
tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
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,
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
stack: Vec::new(),
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
@@ -415,18 +459,29 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
impl PassHost<'_> {
|
||||
fn compute(&mut self, k: Key) -> Value {
|
||||
if let Some(v) = self.results.get(&k) {
|
||||
return v.clone();
|
||||
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.in_progress.contains(&k) || self.depth > 2000 {
|
||||
self.cycle = true;
|
||||
if self.iterate {
|
||||
return self.circular_read(k);
|
||||
}
|
||||
self.cycles.push(k);
|
||||
return Value::Error(CellError::Circ);
|
||||
}
|
||||
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 v = {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
@@ -441,6 +496,15 @@ impl PassHost<'_> {
|
||||
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.
|
||||
@@ -472,6 +536,68 @@ impl PassHost<'_> {
|
||||
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.
|
||||
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.compute(*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
|
||||
@@ -532,6 +658,9 @@ impl Host for PassHost<'_> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -590,6 +719,18 @@ impl Host for PassHost<'_> {
|
||||
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;
|
||||
@@ -825,23 +966,17 @@ impl Calc {
|
||||
let mut rng = self.rng;
|
||||
let tp = prof_now();
|
||||
let (results, spills, cycles) = {
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
exprs: &exprs,
|
||||
results: HashMap::with_capacity_and_hasher(dirty.len(), Default::default()),
|
||||
pending: exprs.keys().copied().collect(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now,
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.compute(*k);
|
||||
}
|
||||
if host.iterate && !host.members.is_empty() {
|
||||
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
|
||||
if std::env::var_os("GRIDCRAFT_PROFILE").is_some() {
|
||||
eprintln!(" iterative calculation: {} passes over {} formulas", passes, host.tainted.len());
|
||||
}
|
||||
}
|
||||
(host.results, host.spills, host.cycles)
|
||||
};
|
||||
self.rng = rng;
|
||||
@@ -961,20 +1096,7 @@ pub fn evaluate(wb: &Workbook, sheet: usize, at: CellRef, formula: &str) -> Valu
|
||||
pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value {
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 0x1234_5678_9ABC_DEF0u64;
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
exprs: &exprs,
|
||||
results: HashMap::default(),
|
||||
pending: HashSet::default(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now: now_serial(),
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now_serial());
|
||||
let mut ev = Evaluator::new(&mut host, sheet, at);
|
||||
ev.value(expr)
|
||||
}
|
||||
@@ -998,7 +1120,7 @@ pub(crate) mod tests {
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
|
||||
use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost};
|
||||
use super::{Calc, HashMap, Host, Intervals, Key, PassHost};
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random cases.
|
||||
fn rng(state: &mut u64) -> u64 {
|
||||
@@ -1214,20 +1336,7 @@ pub(crate) mod tests {
|
||||
// of a real pass partway through; `visit_range` has to cope with the formulas it reads
|
||||
// being calculated under it.
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
pending: exprs.keys().copied().collect(),
|
||||
exprs: &exprs,
|
||||
results: HashMap::default(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now: 0.0,
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, 0.0);
|
||||
|
||||
let mut seen = Vec::new();
|
||||
host.visit_range(0, range, &mut |v| {
|
||||
|
||||
@@ -171,6 +171,105 @@ fn errors_and_cycles() {
|
||||
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
|
||||
}
|
||||
|
||||
impl T {
|
||||
fn iterative(max_iterations: u32, max_change: f64) -> T {
|
||||
let mut t = T::new();
|
||||
t.wb.calc.iterative = true;
|
||||
t.wb.calc.set_max_iterations(max_iterations);
|
||||
t.wb.calc.set_max_change(max_change);
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_stops_at_maximum_iterations() {
|
||||
// A1 =A1+1 never settles: every recalculation adds Maximum Iterations.
|
||||
let mut t = T::iterative(100, 0.001);
|
||||
t.set("A1", "=A1+1");
|
||||
assert_eq!(t.num("A1"), 100.0);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
// An edit outside the loop doesn't recalculate it.
|
||||
t.set("B1", "7");
|
||||
assert_eq!(t.num("A1"), 100.0);
|
||||
// Each recalculation starts from the value the cell shows.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), 200.0);
|
||||
t.wb.calc.set_max_iterations(5);
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), 205.0);
|
||||
// Through a range that holds the formula itself.
|
||||
t.set("C1", "1");
|
||||
t.set("C2", "2");
|
||||
t.set("C3", "=SUM(C1:C3)");
|
||||
assert_eq!(t.num("C3"), 15.0);
|
||||
// Excel's largest Maximum Iterations.
|
||||
let mut t = T::iterative(32767, 0.001);
|
||||
t.set("A1", "=A1+1");
|
||||
assert_eq!(t.num("A1"), 32767.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_converges_within_maximum_change() {
|
||||
let mut t = T::iterative(100, 0.001);
|
||||
t.set("A1", "=(A2+10)/2");
|
||||
t.set("A2", "=A1");
|
||||
assert!((t.num("A1") - 10.0).abs() < 0.002, "{}", t.num("A1"));
|
||||
assert!((t.num("A2") - 10.0).abs() < 0.002, "{}", t.num("A2"));
|
||||
assert!(t.calc.circular.is_empty());
|
||||
// A loop fed by a constant, and a formula that reads the loop, follow a change.
|
||||
t.set("D1", "10");
|
||||
t.set("A1", "=(A2+D1)/2");
|
||||
t.set("E1", "=A1*2");
|
||||
t.set("D1", "30");
|
||||
assert!((t.num("A1") - 30.0).abs() < 0.002, "{}", t.num("A1"));
|
||||
assert!((t.num("E1") - 60.0).abs() < 0.004, "{}", t.num("E1"));
|
||||
// Already settled: recalculating changes it by less than Maximum Change.
|
||||
let a1 = t.num("A1");
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert!((t.num("A1") - a1).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_stops_once_changes_are_below_maximum_change() {
|
||||
// A1 halves its distance to 10 each pass, from 5: 7.5, 8.75, 9.375, 9.6875, and A2 trails
|
||||
// one pass behind. Maximum Change 1 stops once neither moved by 1 or more.
|
||||
let mut t = T::iterative(100, 1.0);
|
||||
t.set("A1", "=(A2+10)/2");
|
||||
assert_eq!(t.num("A1"), 5.0);
|
||||
t.set("A2", "=A1");
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (9.6875, 9.375));
|
||||
// Maximum Iterations 2 stops first.
|
||||
let mut t = T::iterative(2, 1.0);
|
||||
t.set("A1", "=(A2+10)/2");
|
||||
t.set("A2", "=A1");
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (8.75, 7.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_of_text_and_errors() {
|
||||
let mut t = T::iterative(100, 0.001);
|
||||
t.set("A1", "=IF(ISTEXT(A1),A1&\"\",\"go\")");
|
||||
assert_eq!(t.get("A1"), Value::from("go"));
|
||||
t.set("B1", "=B1+1/0");
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Div0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_references_without_iterative_calculation() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=A1+1");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.calc.circular, vec![(0, c("A1"))]);
|
||||
// Switched on, the loop is calculated from 0 rather than from the error.
|
||||
t.wb.calc.iterative = true;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), 100.0);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
t.wb.calc.iterative = false;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
@@ -246,6 +345,7 @@ fn arrays_do_not_spill_in_tables() {
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: false,
|
||||
filter: vec![],
|
||||
});
|
||||
t.set("C4", "=SEQUENCE(2)");
|
||||
t.set("E1", "=SEQUENCE(4)");
|
||||
@@ -462,6 +562,52 @@ fn subtotal_skips_hidden() {
|
||||
assert_eq!(t.num("B2"), 6.0);
|
||||
}
|
||||
|
||||
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
|
||||
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
|
||||
/// under a filtered table adds only the visible rows).
|
||||
#[test]
|
||||
fn subtotal_skips_filtered_rows() {
|
||||
use gridcraft_model::{AutoFilter, FilterCriterion, LineInfo};
|
||||
let mut t = T::new();
|
||||
for (i, v) in [1, 2, 4, 8, 16, 32].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 2), &v.to_string());
|
||||
t.set(&format!("C{}", i + 2), &v.to_string());
|
||||
}
|
||||
let crit = || vec![(0, FilterCriterion::Custom { a: (">".into(), "1".into()), b: None, and: false })];
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
sh.tables.push(gridcraft_model::Table {
|
||||
id: 1,
|
||||
name: "Sales".into(),
|
||||
range: gridcraft_core::RangeRef::parse("A1:A7").unwrap(),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: vec![gridcraft_model::TableColumn { name: "Amount".into(), totals: Default::default(), totals_label: None, formula: None }],
|
||||
style: String::new(),
|
||||
banded_rows: false,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: crit(),
|
||||
});
|
||||
sh.autofilter = Some(AutoFilter { range: gridcraft_core::RangeRef::parse("C1:C7").unwrap(), criteria: crit() });
|
||||
// Rows 3 and 5 are filtered out; row 9 (outside both filters) is hidden by hand.
|
||||
for row in [2, 4, 8] {
|
||||
sh.rows.insert(row, LineInfo { hidden: true, ..Default::default() });
|
||||
}
|
||||
t.set("A9", "64");
|
||||
t.set("E1", "=SUBTOTAL(9,Sales[Amount])");
|
||||
t.set("E2", "=SUBTOTAL(109,Sales[Amount])");
|
||||
t.set("E3", "=SUBTOTAL(9,C2:C7)");
|
||||
t.set("E4", "=SUBTOTAL(9,A2:A9)");
|
||||
t.set("E5", "=SUBTOTAL(109,A2:A9)");
|
||||
t.set("E6", "=SUBTOTAL(2,A2:A7)");
|
||||
t.set("E7", "=AGGREGATE(9,4,A2:A7)");
|
||||
for (at, want) in [("E1", 53.0), ("E2", 53.0), ("E3", 53.0), ("E4", 117.0), ("E5", 53.0), ("E6", 4.0), ("E7", 63.0)] {
|
||||
assert_eq!(t.num(at), want, "{at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recalc_all_after_load() {
|
||||
let mut t = T::new();
|
||||
@@ -783,3 +929,42 @@ fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() {
|
||||
|
||||
assert_eq!(walked, None, "walked a block holding a spill it can't see");
|
||||
}
|
||||
|
||||
/// Trim references evaluate as `TRIMRANGE(range, code, code)`.
|
||||
#[test]
|
||||
fn trim_references() {
|
||||
let mut t = T::new();
|
||||
t.set("C3", "1");
|
||||
t.set("D5", "2");
|
||||
t.set("A20", "5");
|
||||
t.set("A21", "6");
|
||||
let cases = [
|
||||
("J1", "=ROWS(A1.:.F10)", "=ROWS(TRIMRANGE(A1:F10,3,3))", 3.0),
|
||||
("J2", "=COLUMNS(A1.:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,3,3))", 2.0),
|
||||
("J3", "=ROWS(A1:.F10)", "=ROWS(TRIMRANGE(A1:F10,2,2))", 5.0),
|
||||
("J4", "=COLUMNS(A1:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,2,2))", 4.0),
|
||||
("J5", "=ROWS(A1.:F10)", "=ROWS(TRIMRANGE(A1:F10,1,1))", 8.0),
|
||||
("J6", "=MIN(ROW(A1.:F10))", "=MIN(ROW(TRIMRANGE(A1:F10,1,1)))", 3.0),
|
||||
("J7", "=SUM(A:.A)", "=SUM(TRIMRANGE(A:A,2,2))", 11.0),
|
||||
("J8", "=ROWS(A:.A)", "=ROWS(TRIMRANGE(A:A,2,2))", 21.0),
|
||||
("J9", "=ROWS(A.:.A)", "=ROWS(TRIMRANGE(A:A,3,3))", 2.0),
|
||||
("J10", "=ROWS(20.:.30)", "=ROWS(TRIMRANGE(20:30,3,3))", 2.0),
|
||||
];
|
||||
for (at, f, _, _) in cases {
|
||||
t.set(at, f);
|
||||
}
|
||||
for (i, (at, f, same, want)) in cases.iter().enumerate() {
|
||||
assert_eq!(t.num(at), *want, "{f}");
|
||||
let k = format!("K{}", i + 1);
|
||||
t.set(&k, same);
|
||||
assert_eq!(t.num(&k), *want, "{same}");
|
||||
}
|
||||
// The trimmed block spills and follows edits to the full range.
|
||||
t.set("L1", "=C1.:.F10");
|
||||
assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0));
|
||||
t.set("A22", "4");
|
||||
assert_eq!((t.num("J7"), t.num("J8")), (15.0, 22.0));
|
||||
// Nothing but blanks.
|
||||
t.set("K20", "=SUM(Q1:.Q9)");
|
||||
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! documented semantics: trim_rows / trim_cols 0 none, 1 leading, 2 trailing, 3 both).
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_formula::{Expr, TrimMode};
|
||||
|
||||
use crate::eval::{Area, Ev, Evaluator, MAX_CELLS};
|
||||
|
||||
@@ -46,6 +46,20 @@ impl Evaluator<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A trim reference `A1.:E10` / `A1:.E10` / `A1.:.E10`: `TRIMRANGE(range, code, code)`.
|
||||
pub(crate) fn trim_ref(&mut self, mode: TrimMode, x: &Expr) -> Ev {
|
||||
let flags = match mode {
|
||||
TrimMode::Leading => (true, false),
|
||||
TrimMode::Trailing => (false, true),
|
||||
TrimMode::All => (true, true),
|
||||
};
|
||||
let ev = self.eval(x);
|
||||
match self.trim_ev(ev, flags, flags) {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => Ev::V(Value::Error(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_mode(&mut self, args: &[Expr], i: usize) -> Result<(bool, bool), CellError> {
|
||||
match args.get(i) {
|
||||
None | Some(Expr::Missing) => Ok((true, true)),
|
||||
@@ -63,8 +77,13 @@ impl Evaluator<'_> {
|
||||
};
|
||||
let rows = self.trim_mode(args, 1)?;
|
||||
let cols = self.trim_mode(args, 2)?;
|
||||
let ev = self.eval(first);
|
||||
self.trim_ev(ev, rows, cols)
|
||||
}
|
||||
|
||||
fn trim_ev(&mut self, ev: Ev, rows: (bool, bool), cols: (bool, bool)) -> Result<Ev, CellError> {
|
||||
let none = rows == (false, false) && cols == (false, false);
|
||||
match self.eval(first) {
|
||||
match ev {
|
||||
Ev::R(areas) => {
|
||||
let [a] = areas.as_slice() else { return Err(CellError::Value) };
|
||||
if none {
|
||||
|
||||
+14
-3
@@ -15,17 +15,18 @@ mod raster;
|
||||
mod render;
|
||||
mod resolve;
|
||||
mod sparkline;
|
||||
mod trend;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind};
|
||||
pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind, Trendline, TrendlineKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use axis::{Scale, format_number, nice_scale};
|
||||
pub use raster::rasterize;
|
||||
pub use render::{Swatch, legend_entries, render};
|
||||
pub use resolve::{default_palette, resolve, series_color};
|
||||
pub use render::{Swatch, legend_entries, render, supports_trendline};
|
||||
pub use resolve::{default_palette, resolve, series_color, series_plot_kind};
|
||||
pub use sparkline::render_sparkline;
|
||||
|
||||
/// Straight (non-premultiplied) RGBA colour.
|
||||
@@ -140,10 +141,19 @@ pub struct SeriesData {
|
||||
pub secondary: bool,
|
||||
/// Draw line segments as a smoothed (spline) curve.
|
||||
pub smooth: bool,
|
||||
/// Trendlines to fit and draw over this series.
|
||||
pub trendlines: Vec<TrendlineData>,
|
||||
/// Number format code of the source values ("General" by default).
|
||||
pub number_format: String,
|
||||
}
|
||||
|
||||
/// A trendline to draw: the model settings with the line colour resolved against the theme.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TrendlineData {
|
||||
pub spec: Trendline,
|
||||
pub color: Rgba,
|
||||
}
|
||||
|
||||
impl Default for SeriesData {
|
||||
fn default() -> Self {
|
||||
SeriesData {
|
||||
@@ -155,6 +165,7 @@ impl Default for SeriesData {
|
||||
kind: ChartKind::ColumnClustered,
|
||||
secondary: false,
|
||||
smooth: false,
|
||||
trendlines: vec![],
|
||||
number_format: "General".into(),
|
||||
}
|
||||
}
|
||||
|
||||
+229
-19
@@ -3,10 +3,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, Theme};
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind};
|
||||
|
||||
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, series_color};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
|
||||
use crate::trend::{auto_name, fit_trendline, moving_average};
|
||||
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
|
||||
|
||||
pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF];
|
||||
@@ -158,20 +159,28 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
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)]
|
||||
}
|
||||
_ => data
|
||||
.series
|
||||
.iter()
|
||||
.take(MAX_SERIES)
|
||||
.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(),
|
||||
_ => {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +204,7 @@ fn sanitize(d: &ChartData) -> ChartData {
|
||||
kind: s.kind,
|
||||
secondary: s.secondary,
|
||||
smooth: s.smooth,
|
||||
trendlines: s.trendlines.iter().take(MAX_TRENDLINES).cloned().collect(),
|
||||
number_format: s.number_format.clone(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -630,6 +640,160 @@ fn value_range(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 }
|
||||
}
|
||||
|
||||
/// Longest forward forecast among `s`'s regression trendlines.
|
||||
fn max_forward(s: &SeriesData) -> f64 {
|
||||
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Longest backward forecast among `s`'s regression trendlines.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
|
||||
/// end were cut.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
|
||||
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.
|
||||
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>,
|
||||
) {
|
||||
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 };
|
||||
let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward));
|
||||
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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -763,14 +927,23 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
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 });
|
||||
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
|
||||
// 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.ceil() as usize).min(MAX_CATS.max(ncat));
|
||||
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines);
|
||||
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 as f32;
|
||||
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 };
|
||||
|
||||
@@ -900,6 +1073,25 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scatter & bubble
|
||||
@@ -922,7 +1114,14 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
.collect()
|
||||
};
|
||||
let all: Vec<Vec<Option<(f64, f64)>>> = data.series.iter().map(pts_of).collect();
|
||||
let Some((xlo, xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return };
|
||||
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);
|
||||
@@ -976,6 +1175,17 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- histogram
|
||||
|
||||
@@ -5,12 +5,14 @@ use gridcraft_core::{CellRef, RangeRef, Value, number_to_text};
|
||||
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
|
||||
use gridcraft_numfmt::{NumberFormat, format_value};
|
||||
|
||||
use crate::{ChartData, Rgba, SeriesData};
|
||||
use crate::{ChartData, Rgba, SeriesData, TrendlineData};
|
||||
|
||||
/// Cap on points per series (keeps whole-column references from exploding).
|
||||
pub(crate) const MAX_POINTS: usize = 100_000;
|
||||
/// Cap on series per chart.
|
||||
pub(crate) const MAX_SERIES: usize = 255;
|
||||
/// Cap on trendlines drawn per series.
|
||||
pub(crate) const MAX_TRENDLINES: usize = 16;
|
||||
|
||||
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
|
||||
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
|
||||
@@ -181,6 +183,11 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's.
|
||||
pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {
|
||||
if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind }
|
||||
}
|
||||
|
||||
/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X
|
||||
/// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the
|
||||
/// default palette derived from the workbook theme accents).
|
||||
@@ -208,6 +215,15 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
}
|
||||
}
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
|
||||
let trendlines = s
|
||||
.trendlines
|
||||
.iter()
|
||||
.take(MAX_TRENDLINES)
|
||||
.map(|t| TrendlineData {
|
||||
spec: t.clone(),
|
||||
color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
|
||||
})
|
||||
.collect();
|
||||
series.push(SeriesData {
|
||||
name,
|
||||
values,
|
||||
@@ -217,6 +233,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
kind,
|
||||
secondary: s.secondary,
|
||||
smooth: s.smooth,
|
||||
trendlines,
|
||||
number_format: ref_format(wb, sheet, &s.values),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat
|
||||
kind,
|
||||
secondary: false,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
number_format: "$#,##0".into(),
|
||||
}
|
||||
}
|
||||
@@ -226,6 +227,7 @@ fn resolve_from_workbook() {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
Series {
|
||||
name: None,
|
||||
@@ -236,6 +238,7 @@ fn resolve_from_workbook() {
|
||||
secondary: true,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
Series {
|
||||
name: Some("Literal".into()),
|
||||
@@ -246,6 +249,7 @@ fn resolve_from_workbook() {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
@@ -283,6 +287,7 @@ fn resolve_from_workbook() {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
Series {
|
||||
name: None,
|
||||
@@ -293,6 +298,7 @@ fn resolve_from_workbook() {
|
||||
secondary: true,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
@@ -308,6 +314,7 @@ fn resolve_from_workbook() {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
|
||||
@@ -324,6 +331,7 @@ fn resolve_from_workbook() {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 7, &c);
|
||||
assert_eq!(d.series.len(), 1);
|
||||
@@ -583,6 +591,28 @@ fn fuzz_random_data_and_sizes() {
|
||||
kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize],
|
||||
secondary: r.next().is_multiple_of(3),
|
||||
smooth: r.next().is_multiple_of(4),
|
||||
trendlines: (0..r.next() % 3)
|
||||
.map(|_| TrendlineData {
|
||||
spec: Trendline {
|
||||
kind: [
|
||||
TrendlineKind::Linear,
|
||||
TrendlineKind::Exponential,
|
||||
TrendlineKind::Logarithmic,
|
||||
TrendlineKind::Polynomial((r.next() % 8) as u8),
|
||||
TrendlineKind::Power,
|
||||
TrendlineKind::MovingAverage((r.next() % 6) as u32),
|
||||
][(r.next() % 6) as usize],
|
||||
forward: [0.0, 1.5, -2.0, f64::NAN, 1e300][(r.next() % 5) as usize],
|
||||
backward: [0.0, 0.5, f64::INFINITY][(r.next() % 3) as usize],
|
||||
intercept: [None, Some(0.0), Some(-5.0), Some(f64::NAN)][(r.next() % 4) as usize],
|
||||
display_equation: r.next().is_multiple_of(2),
|
||||
display_r_squared: r.next().is_multiple_of(2),
|
||||
width: [None, Some(0.0), Some(f32::NAN), Some(3.0)][(r.next() % 4) as usize],
|
||||
..Trendline::default()
|
||||
},
|
||||
color: [0, 0, 0, 255],
|
||||
})
|
||||
.collect(),
|
||||
number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(),
|
||||
})
|
||||
.collect();
|
||||
@@ -631,6 +661,193 @@ fn sparklines() {
|
||||
assert!(render_sparkline(SparklineKind::Line, &[], [0; 4], false, 10.0, 10.0).is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- trendlines
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn fit(kind: TrendlineKind, pts: &[(f64, f64)]) -> crate::trend::TrendFit {
|
||||
crate::trend::fit_trendline(&Trendline { kind, ..Trendline::default() }, pts).expect("fits")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_trendline_matches_least_squares() {
|
||||
// Slope 0.6, intercept 2.2, R² = 1 - 2.4 / 6.
|
||||
let f = fit(TrendlineKind::Linear, &[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
assert!(close(f.coef[0], 2.2) && close(f.coef[1], 0.6), "{:?}", f.coef);
|
||||
assert!(close(f.r2.unwrap(), 0.6));
|
||||
assert_eq!(f.equation(), "y = 0.6x + 2.2");
|
||||
assert_eq!(f.r_squared(), "R² = 0.6");
|
||||
assert!(close(f.eval(10.0).unwrap(), 8.2));
|
||||
let down = fit(TrendlineKind::Linear, &[(0.0, 1.0), (1.0, -1.0), (2.0, -3.0)]);
|
||||
assert_eq!(down.equation(), "y = -2x + 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_intercept_fits_through_it() {
|
||||
// Through the origin: slope Σxy / Σx² = 30 / 14; R² against the uncentred total.
|
||||
let pts = [(1.0, 2.0), (2.0, 5.0), (3.0, 6.0)];
|
||||
let f = crate::trend::fit_trendline(&Trendline { intercept: Some(0.0), ..Trendline::default() }, &pts).unwrap();
|
||||
assert!(close(f.coef[0], 0.0) && close(f.coef[1], 15.0 / 7.0), "{:?}", f.coef);
|
||||
let ss_res: f64 = pts.iter().map(|(x, y)| (y - 15.0 / 7.0 * x).powi(2)).sum();
|
||||
assert!(close(f.r2.unwrap(), 1.0 - ss_res / 65.0));
|
||||
let q = crate::trend::fit_trendline(
|
||||
&Trendline { kind: TrendlineKind::Polynomial(2), intercept: Some(2.0), ..Trendline::default() },
|
||||
&[(1.0, 0.0), (2.0, 0.0), (3.0, 2.0), (4.0, 6.0)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(close(q.coef[0], 2.0) && close(q.coef[1], -3.0) && close(q.coef[2], 1.0), "{:?}", q.coef);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_trendlines_recover_their_curves() {
|
||||
let xs: [f64; 6] = [0.5, 1.0, 2.0, 3.0, 4.5, 6.0];
|
||||
let e = fit(TrendlineKind::Exponential, &xs.map(|x| (x, 3.0 * (0.5 * x).exp())));
|
||||
assert!(close(e.coef[0].exp(), 3.0) && close(e.coef[1], 0.5));
|
||||
assert_eq!(e.equation(), "y = 3e^(0.5x)");
|
||||
let l = fit(TrendlineKind::Logarithmic, &xs.map(|x| (x, 2.0 * x.ln() + 1.0)));
|
||||
assert!(close(l.coef[1], 2.0) && close(l.coef[0], 1.0));
|
||||
assert_eq!(l.equation(), "y = 2ln(x) + 1");
|
||||
let p = fit(TrendlineKind::Power, &xs.map(|x| (x, 4.0 * x.powf(1.5))));
|
||||
assert!(close(p.coef[0].exp(), 4.0) && close(p.coef[1], 1.5));
|
||||
assert_eq!(p.equation(), "y = 4x^1.5");
|
||||
assert!(close(p.r2.unwrap(), 1.0));
|
||||
assert_eq!(p.eval(-1.0), None, "power is undefined left of zero");
|
||||
// Points the transform can't take are skipped, as Excel documents.
|
||||
let skip = fit(TrendlineKind::Exponential, &[(1.0, 2.0f64.exp()), (2.0, 0.0), (3.0, -4.0), (4.0, 8.0f64.exp())]);
|
||||
assert!(close(skip.coef[1], 2.0) && close(skip.coef[0], 0.0), "{:?}", skip.coef);
|
||||
assert!(
|
||||
crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::Power, ..Trendline::default() }, &[(-1.0, 1.0), (0.0, 2.0), (3.0, 3.0)])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polynomial_trendlines_are_stable() {
|
||||
let q = fit(TrendlineKind::Polynomial(2), &[0.0, 1.0, 2.0, 3.0, 4.0].map(|x| (x, x * x - 3.0 * x + 2.0)));
|
||||
assert!(close(q.coef[0], 2.0) && close(q.coef[1], -3.0) && close(q.coef[2], 1.0), "{:?}", q.coef);
|
||||
assert_eq!(q.equation(), "y = x² - 3x + 2");
|
||||
// Year-valued x would make the raw normal equations hopelessly ill-conditioned.
|
||||
let curve = |x: f64| {
|
||||
let t = x - 2000.0;
|
||||
0.001 * t.powi(6) - 0.02 * t.powi(5) + 0.1 * t.powi(3) - t + 7.0
|
||||
};
|
||||
let years: Vec<(f64, f64)> = (2001..=2012).map(|x| (f64::from(x), curve(f64::from(x)))).collect();
|
||||
let six = fit(TrendlineKind::Polynomial(6), &years);
|
||||
for x in [2001.0, 2006.5, 2012.0, 2014.0] {
|
||||
let (got, want) = (six.eval(x).unwrap(), curve(x));
|
||||
assert!((got - want).abs() <= 1e-6 * (1.0 + want.abs()), "{x}: {got} vs {want}");
|
||||
}
|
||||
assert!(six.r2.unwrap() > 0.999_999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_trendlines_fit_nothing() {
|
||||
let lin = |pts: &[(f64, f64)]| crate::trend::fit_trendline(&Trendline::default(), pts);
|
||||
assert!(lin(&[]).is_none());
|
||||
assert!(lin(&[(1.0, 1.0)]).is_none());
|
||||
assert!(lin(&[(2.0, 1.0), (2.0, 5.0), (2.0, 3.0)]).is_none(), "all x equal is singular");
|
||||
assert!(lin(&[(f64::NAN, 1.0), (1.0, f64::INFINITY), (2.0, 3.0)]).is_none());
|
||||
assert!(
|
||||
crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::Polynomial(3), ..Trendline::default() }, &[(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)])
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, &[(1.0, 1.0), (2.0, 2.0)])
|
||||
.is_none()
|
||||
);
|
||||
let flat = lin(&[(1.0, 4.0), (2.0, 4.0), (3.0, 4.0)]).unwrap();
|
||||
assert_eq!((flat.equation(), flat.r2), ("y = 4".to_string(), None));
|
||||
assert!(lin(&[(0.0, 1e300), (1.0, -1e300), (2.0, 1e300)]).is_none_or(|f| f.coef.iter().all(|c| c.is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_average_and_labels() {
|
||||
let pts = [(1.0, 1.0), (2.0, 2.0), (3.0, 6.0), (4.0, f64::NAN), (5.0, 4.0)];
|
||||
assert_eq!(crate::trend::moving_average(&pts, 2), vec![(2.0, 1.5), (3.0, 4.0), (5.0, 5.0)]);
|
||||
assert_eq!(crate::trend::moving_average(&pts, 3), vec![(3.0, 3.0), (5.0, 4.0)]);
|
||||
assert!(crate::trend::moving_average(&pts, 9).is_empty());
|
||||
assert_eq!(crate::trend::num_text(0.000012345), "1.2345E-05");
|
||||
assert_eq!(crate::trend::num_text(-0.00001), "-1E-05");
|
||||
assert_eq!(crate::trend::num_text(1234.56789), "1234.5679");
|
||||
assert_eq!(crate::trend::auto_name(TrendlineKind::MovingAverage(3), "Sales"), "3 per. Mov. Avg. (Sales)");
|
||||
assert_eq!(crate::trend::auto_name(TrendlineKind::Exponential, "Sales"), "Expon. (Sales)");
|
||||
}
|
||||
|
||||
fn lines_of(prims: &[Prim], color: Rgba) -> Vec<&Vec<[f32; 2]>> {
|
||||
prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Line { pts, color: c, .. } if *c == color => Some(pts),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_chart_draws_trendline_forecast_and_labels() {
|
||||
let m = ApproxMeasure;
|
||||
let tc: Rgba = [1, 2, 3, 255];
|
||||
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.trendlines =
|
||||
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
|
||||
let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
assert_sane(&prims, 600.0, 400.0);
|
||||
let data = lines_of(&prims, [10, 20, 30, 255]);
|
||||
let trend = lines_of(&prims, tc);
|
||||
assert_eq!((data.len(), trend.len()), (1, 1));
|
||||
let (d0, dn) = (data[0][0], data[0][3]);
|
||||
let (t0, tn) = (trend[0][0], *trend[0].last().unwrap());
|
||||
// Exact data: the trendline starts on the first point and runs through the last,
|
||||
// then on one more category (the forecast slot).
|
||||
assert!((t0[0] - d0[0]).abs() < 0.01 && (t0[1] - d0[1]).abs() < 0.01, "{t0:?} vs {d0:?}");
|
||||
let step = (dn[0] - d0[0]) / 3.0;
|
||||
assert!((tn[0] - (dn[0] + step)).abs() < 0.01, "forecast end {tn:?}, last point {dn:?}");
|
||||
assert!(tn[1] > dn[1], "keeps falling past the data");
|
||||
let t = texts(&prims);
|
||||
assert!(t.contains(&"y = -2x + 11") && t.contains(&"R² = 1"), "{t:?}");
|
||||
assert!(legend_entries(&chart(ChartKind::Line), &d).iter().any(|(n, col, sw)| n == "Linear (Sales)" && *col == tc && *sw == Swatch::Line));
|
||||
// Not on chart kinds Excel doesn't offer trendlines on.
|
||||
let pie = render(&chart(ChartKind::Pie), &d, 600.0, 400.0, &m);
|
||||
assert!(lines_of(&pie, tc).is_empty());
|
||||
// One point: nothing to fit, nothing drawn.
|
||||
s.values.truncate(1);
|
||||
let one = ChartData { categories: vec!["a".into()], series: vec![s] };
|
||||
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
||||
let m = ApproxMeasure;
|
||||
let xs = [1.0, 2.0, 4.0, 8.0];
|
||||
let mut s = series("P", &xs.map(|x| 4.0 * x * x), [10, 20, 30, 255], ChartKind::Scatter);
|
||||
s.x = Some(xs.iter().map(|x| Some(*x)).collect());
|
||||
let colors: [Rgba; 3] = [[1, 0, 0, 255], [2, 0, 0, 255], [3, 0, 0, 255]];
|
||||
s.trendlines = vec![
|
||||
TrendlineData {
|
||||
spec: Trendline { kind: TrendlineKind::Power, backward: 0.5, display_equation: true, ..Trendline::default() },
|
||||
color: colors[0],
|
||||
},
|
||||
// A steep exponential leaves the plot box and is clipped to it.
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
|
||||
];
|
||||
let d = ChartData { categories: vec![], series: vec![s] };
|
||||
let mut c = chart(ChartKind::Scatter);
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
assert_sane(&prims, 600.0, 400.0);
|
||||
for c in colors {
|
||||
assert!(!lines_of(&prims, c).is_empty(), "{c:?} not drawn");
|
||||
}
|
||||
assert_eq!(lines_of(&prims, colors[2])[0].len(), 3, "moving average: one point per full window");
|
||||
assert!(texts(&prims).contains(&"y = 4x^2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treemap_and_histogram_shapes() {
|
||||
let d = ChartData {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Trendline maths: least-squares fits behind chart trendlines and moving averages.
|
||||
//!
|
||||
//! Exponential, logarithmic and power trendlines are linear fits of transformed data
|
||||
//! (`ln y` against `x`, `y` against `ln x`, `ln y` against `ln x`), the way Excel documents them;
|
||||
//! points where the transform is undefined (non-positive values) are skipped and R² is reported
|
||||
//! for the transformed fit. Polynomials solve the normal equations on centred, scaled x.
|
||||
|
||||
use gridcraft_model::{Trendline, TrendlineKind};
|
||||
|
||||
/// A fitted regression trendline.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct TrendFit {
|
||||
kind: TrendlineKind,
|
||||
/// Coefficients `c0..=cd` of `Y = Σ cₖ·Xᵏ` in the transformed variables, for the equation.
|
||||
pub coef: Vec<f64>,
|
||||
/// The same polynomial in `u = (X - center) / scale`, for stable evaluation.
|
||||
center: f64,
|
||||
scale: f64,
|
||||
a: Vec<f64>,
|
||||
/// Coefficient of determination; `None` when undefined (all Y equal).
|
||||
pub r2: Option<f64>,
|
||||
}
|
||||
|
||||
/// Polynomial degree in the transformed variable; `None` for moving averages.
|
||||
fn degree(k: TrendlineKind) -> Option<usize> {
|
||||
match k {
|
||||
TrendlineKind::Linear | TrendlineKind::Exponential | TrendlineKind::Logarithmic | TrendlineKind::Power => Some(1),
|
||||
TrendlineKind::Polynomial(o) => Some(usize::from(o.clamp(2, 6))),
|
||||
TrendlineKind::MovingAverage(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_x(k: TrendlineKind) -> bool {
|
||||
matches!(k, TrendlineKind::Logarithmic | TrendlineKind::Power)
|
||||
}
|
||||
|
||||
fn log_y(k: TrendlineKind) -> bool {
|
||||
matches!(k, TrendlineKind::Exponential | TrendlineKind::Power)
|
||||
}
|
||||
|
||||
/// Fits `t` to the points `(x, y)`. `None` for moving averages and degenerate data (too few
|
||||
/// usable points, all x equal, a singular system, non-finite results).
|
||||
pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFit> {
|
||||
let kind = t.kind;
|
||||
let d = degree(kind)?;
|
||||
let data: Vec<(f64, f64)> = pts
|
||||
.iter()
|
||||
.filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0))
|
||||
.map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y }))
|
||||
.collect();
|
||||
if data.len() < 2 || data.len() <= d {
|
||||
return None;
|
||||
}
|
||||
// Set Intercept applies to linear, exponential and polynomial trendlines; it fixes Y at x = 0.
|
||||
let fixed = match kind {
|
||||
TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept,
|
||||
TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|b| b.is_finite());
|
||||
let n = data.len() as f64;
|
||||
let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::<f64>() / n };
|
||||
let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let first = usize::from(fixed.is_some());
|
||||
let b0 = fixed.unwrap_or(0.0);
|
||||
// Normal equations over the unknown powers `first..=d` of u.
|
||||
let m = d + 1 - first;
|
||||
let mut ata = vec![vec![0.0f64; m]; m];
|
||||
let mut aty = vec![0.0f64; m];
|
||||
for &(x, y) in &data {
|
||||
let u = (x - center) / scale;
|
||||
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
|
||||
for (i, pi) in pows.iter().enumerate() {
|
||||
if let Some(v) = aty.get_mut(i) {
|
||||
*v += pi * (y - b0);
|
||||
}
|
||||
for (j, pj) in pows.iter().enumerate() {
|
||||
if let Some(v) = ata.get_mut(i).and_then(|r| r.get_mut(j)) {
|
||||
*v += pi * pj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sol = solve(ata, aty)?;
|
||||
let mut a = vec![b0; d + 1];
|
||||
for (k, v) in sol.into_iter().enumerate() {
|
||||
if let Some(slot) = a.get_mut(k + first) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
let coef = expand(&a, center, scale)?;
|
||||
let fit = TrendFit { kind, coef, center, scale, a, r2: None };
|
||||
let mean = data.iter().map(|p| p.1).sum::<f64>() / n;
|
||||
let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64);
|
||||
for &(x, y) in &data {
|
||||
ss_res += (y - fit.poly(x)).powi(2);
|
||||
// A set intercept is LINEST(y - b, x, FALSE): the total sum of squares is not centred.
|
||||
ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) };
|
||||
}
|
||||
let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot);
|
||||
Some(TrendFit { r2, ..fit })
|
||||
}
|
||||
|
||||
/// Gaussian elimination with partial pivoting; `None` when (nearly) singular.
|
||||
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
let big = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
if !(big.is_finite() && big > 0.0) {
|
||||
return None;
|
||||
}
|
||||
for col in 0..n {
|
||||
let piv = (col..n).max_by(|&i, &j| {
|
||||
let vi = a.get(i).and_then(|r| r.get(col)).map_or(0.0, |v| v.abs());
|
||||
let vj = a.get(j).and_then(|r| r.get(col)).map_or(0.0, |v| v.abs());
|
||||
vi.total_cmp(&vj)
|
||||
})?;
|
||||
a.swap(col, piv);
|
||||
b.swap(col, piv);
|
||||
let p = *a.get(col)?.get(col)?;
|
||||
if !p.is_finite() || p.abs() <= big * 1e-13 {
|
||||
return None;
|
||||
}
|
||||
let prow = a.get(col)?.clone();
|
||||
let pb = *b.get(col)?;
|
||||
for r in col + 1..n {
|
||||
let row = a.get_mut(r)?;
|
||||
let f = *row.get(col)? / p;
|
||||
for (v, pv) in row.iter_mut().zip(&prow).skip(col) {
|
||||
*v -= f * pv;
|
||||
}
|
||||
*b.get_mut(r)? -= f * pb;
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0f64; n];
|
||||
for r in (0..n).rev() {
|
||||
let row = a.get(r)?;
|
||||
let s: f64 = (r + 1..n).map(|c| row.get(c).copied().unwrap_or(0.0) * x.get(c).copied().unwrap_or(0.0)).sum();
|
||||
let v = (b.get(r)? - s) / row.get(r)?;
|
||||
*x.get_mut(r)? = v;
|
||||
}
|
||||
x.iter().all(|v| v.is_finite()).then_some(x)
|
||||
}
|
||||
|
||||
/// Coefficients in X of `Σ aₖ·((X - c) / s)ᵏ` (binomial expansion).
|
||||
fn expand(a: &[f64], c: f64, s: f64) -> Option<Vec<f64>> {
|
||||
let mut out = vec![0.0f64; a.len()];
|
||||
for (k, ak) in a.iter().enumerate() {
|
||||
let lead = ak / s.powi(k as i32);
|
||||
let mut binom = 1.0f64;
|
||||
for j in 0..=k {
|
||||
// C(k, j) · (-c)^(k-j) · X^j
|
||||
if let Some(o) = out.get_mut(j) {
|
||||
*o += lead * binom * (-c).powi((k - j) as i32);
|
||||
}
|
||||
binom = binom * (k - j) as f64 / (j + 1) as f64;
|
||||
}
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
impl TrendFit {
|
||||
/// The fitted polynomial at transformed `x` (Horner in u).
|
||||
fn poly(&self, x: f64) -> f64 {
|
||||
let u = (x - self.center) / self.scale;
|
||||
self.a.iter().rev().fold(0.0, |acc, c| acc * u + c)
|
||||
}
|
||||
|
||||
/// The trendline's y at `x`; `None` outside its domain (x ≤ 0 for log/power) or when not finite.
|
||||
pub(crate) fn eval(&self, x: f64) -> Option<f64> {
|
||||
let xx = if log_x(self.kind) { if x > 0.0 { x.ln() } else { return None } } else { x };
|
||||
let p = self.poly(xx);
|
||||
let y = if log_y(self.kind) { p.exp() } else { p };
|
||||
y.is_finite().then_some(y)
|
||||
}
|
||||
|
||||
/// Equation label text, e.g. `y = 2x + 1`, `y = 0.5x² - 2x + 1`, `y = 3e^(0.2x)`.
|
||||
pub(crate) fn equation(&self) -> String {
|
||||
let c0 = self.coef.first().copied().unwrap_or(0.0);
|
||||
let c1 = self.coef.get(1).copied().unwrap_or(0.0);
|
||||
match self.kind {
|
||||
TrendlineKind::Exponential => format!("y = {}e^({}x)", num_text(c0.exp()), num_text(c1)),
|
||||
TrendlineKind::Power => format!("y = {}x^{}", num_text(c0.exp()), num_text(c1)),
|
||||
TrendlineKind::Logarithmic => terms(&[(c1, "ln(x)"), (c0, "")]),
|
||||
_ => {
|
||||
let parts: Vec<(f64, &str)> = self.coef.iter().enumerate().rev().map(|(k, c)| (*c, POWERS.get(k).copied().unwrap_or("x"))).collect();
|
||||
terms(&parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// R² label text, e.g. `R² = 0.9963`.
|
||||
pub(crate) fn r_squared(&self) -> String {
|
||||
match self.r2 {
|
||||
Some(r) => format!("R² = {}", num_text(r)),
|
||||
None => "R² = #N/A".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const POWERS: [&str; 7] = ["", "x", "x²", "x³", "x⁴", "x⁵", "x⁶"];
|
||||
|
||||
/// `y = a·t1 ± b·t2 …`, skipping terms whose coefficient shows as 0.
|
||||
fn terms(parts: &[(f64, &str)]) -> String {
|
||||
let mut s = String::from("y = ");
|
||||
let mut first = true;
|
||||
for (c, t) in parts {
|
||||
let txt = num_text(c.abs());
|
||||
if txt == "0" {
|
||||
continue;
|
||||
}
|
||||
let neg = *c < 0.0;
|
||||
if first {
|
||||
if neg {
|
||||
s.push('-');
|
||||
}
|
||||
} else {
|
||||
s.push_str(if neg { " - " } else { " + " });
|
||||
}
|
||||
// A unit coefficient on a power of x is left out: `y = x² - x + 1`.
|
||||
if txt != "1" || t.is_empty() {
|
||||
s.push_str(&txt);
|
||||
}
|
||||
s.push_str(t);
|
||||
first = false;
|
||||
}
|
||||
if first {
|
||||
s.push('0');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A coefficient as trendline labels show it: up to four decimals, scientific when tiny or huge.
|
||||
pub(crate) fn num_text(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "#NUM!".into();
|
||||
}
|
||||
let a = v.abs();
|
||||
if a == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
if !(1e-4..1e11).contains(&a) {
|
||||
let s = format!("{v:.4E}");
|
||||
let (mant, exp) = s.split_once('E').unwrap_or((s.as_str(), "0"));
|
||||
let mant = trim_zeros(mant);
|
||||
let e: i32 = exp.parse().unwrap_or(0);
|
||||
return format!("{mant}E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs());
|
||||
}
|
||||
let s = trim_zeros(&format!("{v:.4}"));
|
||||
if s == "-0" { "0".into() } else { s }
|
||||
}
|
||||
|
||||
fn trim_zeros(s: &str) -> String {
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// Moving average of `period` consecutive points in plotted order: point `i` (from
|
||||
/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`.
|
||||
pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
|
||||
}
|
||||
|
||||
/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`.
|
||||
pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String {
|
||||
let k = match kind {
|
||||
TrendlineKind::Linear => "Linear".to_string(),
|
||||
TrendlineKind::Exponential => "Expon.".into(),
|
||||
TrendlineKind::Logarithmic => "Log.".into(),
|
||||
TrendlineKind::Polynomial(_) => "Poly.".into(),
|
||||
TrendlineKind::Power => "Power".into(),
|
||||
TrendlineKind::MovingAverage(p) => format!("{} per. Mov. Avg.", p.clamp(2, 255)),
|
||||
};
|
||||
format!("{k} ({series})")
|
||||
}
|
||||
@@ -25,4 +25,5 @@ gridcraft-pdf = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -444,6 +444,9 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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)
|
||||
});
|
||||
@@ -624,6 +627,8 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
},
|
||||
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;
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
}
|
||||
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
|
||||
let expr = wb.name_call_case(expr);
|
||||
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().
|
||||
@@ -190,6 +191,28 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
}
|
||||
|
||||
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
|
||||
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
|
||||
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
|
||||
/// unknown functions are skipped.
|
||||
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
|
||||
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(()),
|
||||
}
|
||||
}
|
||||
|
||||
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)? {
|
||||
@@ -210,10 +233,17 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
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.
|
||||
let range = cx.sel.current();
|
||||
// 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);
|
||||
@@ -578,6 +608,7 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
|
||||
let d = s.doc_mut()?;
|
||||
let mut label = String::new();
|
||||
let now = d.wb.clone();
|
||||
for _ in 0..steps {
|
||||
let Some(e) = d.undo.pop() else { break };
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||
@@ -586,6 +617,7 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
d.revision += 1;
|
||||
Ok(json!({"undone": label}))
|
||||
@@ -595,6 +627,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
|
||||
let d = s.doc_mut()?;
|
||||
let mut label = String::new();
|
||||
let now = d.wb.clone();
|
||||
for _ in 0..steps {
|
||||
let Some(e) = d.redo.pop() else { break };
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||
@@ -603,6 +636,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
d.revision += 1;
|
||||
Ok(json!({"redone": label}))
|
||||
@@ -972,7 +1006,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
e
|
||||
};
|
||||
let mut moved = Formula::from_expr(e);
|
||||
moved.array = f.array;
|
||||
// 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)?)));
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved =
|
||||
@@ -1139,6 +1175,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
/// 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>)> =
|
||||
@@ -1146,16 +1186,21 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
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);
|
||||
if text != f.text
|
||||
let array = match f.array {
|
||||
Some(a) if on_target => super::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 = f.array;
|
||||
nf.array = array;
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
}
|
||||
@@ -1554,6 +1599,22 @@ mod tests {
|
||||
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"));
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Chart Element",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth, on?: bool, position?: bottom|top|left|right}",
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
has_doc,
|
||||
add_element
|
||||
),
|
||||
@@ -316,12 +316,68 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).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)
|
||||
}
|
||||
|
||||
fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let n = u32_param(p, "layout").unwrap_or(1);
|
||||
with_chart(s, p, |c| {
|
||||
@@ -421,6 +477,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
with_chart(s, p, move |c| {
|
||||
if let Some((series, source, by_rows)) = rebuilt {
|
||||
c.series = series;
|
||||
super::insert::scatter_x_from_first(c.kind, &mut c.series);
|
||||
c.source = Some(source);
|
||||
c.by_rows = by_rows;
|
||||
}
|
||||
@@ -651,6 +708,19 @@ fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
|
||||
wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true });
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut views = stash_get(&d.wb, VIEWS_NAME);
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::DocState;
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
||||
@@ -55,7 +55,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({})));
|
||||
return ok();
|
||||
};
|
||||
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
|
||||
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||||
let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||||
let imported =
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
@@ -64,8 +65,10 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
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.
|
||||
let path = str_param(p, "path").filter(|_| !imported).map(str::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
|
||||
// an unprotected copy.
|
||||
let path = str_param(p, "path").filter(|_| !imported && !encrypted).map(str::to_string);
|
||||
// Replace an untouched blank Book1 like Excel does.
|
||||
if s.documents().len() == 1
|
||||
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
|
||||
@@ -73,10 +76,12 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
s.close_document(0);
|
||||
}
|
||||
let i = s.add_document(DocState::new(wb, path, title));
|
||||
if !warnings.is_empty() {
|
||||
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
|
||||
if warnings.len() > 5 {
|
||||
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
|
||||
// The decrypted-workbook notice is for the info bar, not a message box.
|
||||
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
|
||||
if !shown.is_empty() {
|
||||
let mut message = shown.iter().take(5).copied().collect::<Vec<_>>().join("\n\n");
|
||||
if shown.len() > 5 {
|
||||
message.push_str(&format!("\n\n{} additional import warnings.", shown.len() - 5));
|
||||
}
|
||||
s.ui_requests.push(crate::UiRequest::Message(message));
|
||||
}
|
||||
|
||||
@@ -358,6 +358,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
});
|
||||
let q = gridcraft_formula::quote_sheet(&sheet_name);
|
||||
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
|
||||
@@ -371,6 +372,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
secondary: false,
|
||||
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
sheet.charts.push(Chart {
|
||||
|
||||
@@ -561,8 +561,11 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how };
|
||||
edit(s, |cx| {
|
||||
let mut touched = Vec::new();
|
||||
// Areas whose merging changes: a merged cell blocks a dynamic array's spill.
|
||||
let mut areas: Vec<RangeRef> = ranges.clone();
|
||||
for r in &ranges {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
areas.extend(sh.merges.iter().filter(|m| m.intersects(r)));
|
||||
sh.merges.retain(|m| !m.intersects(r));
|
||||
match how {
|
||||
"unmerge" => {}
|
||||
@@ -593,6 +596,17 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spills over the areas, and blocked ones that could reach them, are evaluated again.
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for a in &areas {
|
||||
touched.extend(sh.spill_ranges.iter().filter(|(_, r)| r.intersects(a)).map(|(anchor, _)| (sheet, *anchor)));
|
||||
let reach = RangeRef::new(CellRef::new(0, 0), a.end);
|
||||
let blocked = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
|
||||
touched.extend(blocked.map(|(c, _)| (sheet, c)));
|
||||
}
|
||||
cx.changed.extend(touched);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -1019,7 +1033,9 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let st = cx.wb.styles.get(sh.style_id(cell));
|
||||
let text = crate::display::cell_text(&cx.wb, sh, cell);
|
||||
for line in text.lines() {
|
||||
max = max.max(approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0);
|
||||
max = max.max(
|
||||
approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell),
|
||||
);
|
||||
}
|
||||
}
|
||||
if max > 0.0 {
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Calculation Options",
|
||||
["Formulas", "Calculation"],
|
||||
None,
|
||||
"{mode: automatic|automaticExceptTables|manual, iterative?, maxIterations?, maxChange?}",
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
has_doc,
|
||||
calc_options
|
||||
),
|
||||
@@ -78,18 +78,28 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
if !sel.is_single() {
|
||||
// A block: totals below each column (and to the right when a blank column is included).
|
||||
let num_end = sel.end;
|
||||
let below = CellRef::new(num_end.row + 1, 0);
|
||||
let mut writes = Vec::new();
|
||||
let last_row_blank = (sel.start.col..=sel.end.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, below.row) };
|
||||
for c in sel.start.col..=sel.end.col {
|
||||
let r = RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c));
|
||||
writes.push((CellRef::new(out_row, c), format!("={func}({})", r.a1())));
|
||||
}
|
||||
for (c, f) in writes {
|
||||
s.execute("cell.set", json!({"cell": c.a1(), "input": f}))?;
|
||||
// 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()));
|
||||
}
|
||||
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();
|
||||
}
|
||||
// Single cell: guess the range above, else to the left.
|
||||
@@ -554,6 +564,7 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
_ => None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let before = cx.wb.calc.clone();
|
||||
if let Some(m) = mode {
|
||||
cx.wb.calc.mode = m;
|
||||
}
|
||||
@@ -561,15 +572,19 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.calc.iterative = v;
|
||||
}
|
||||
if let Some(v) = u32_param(p, "maxIterations") {
|
||||
cx.wb.calc.max_iterations = v.clamp(1, 32767);
|
||||
cx.wb.calc.set_max_iterations(v);
|
||||
}
|
||||
if let Some(v) = f64_param(p, "maxChange") {
|
||||
cx.wb.calc.max_change = v.abs();
|
||||
cx.wb.calc.set_max_change(v);
|
||||
}
|
||||
if mode == Some(CalcMode::Automatic) {
|
||||
let c = &cx.wb.calc;
|
||||
// Switching to automatic, or changing how circular references are calculated outside
|
||||
// manual mode, recalculates the workbook.
|
||||
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
|
||||
cx.structural = true;
|
||||
}
|
||||
Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)}))
|
||||
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
|
||||
//! conditional formatting (Home › Styles) and symbols.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -224,6 +224,12 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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"));
|
||||
}
|
||||
@@ -234,9 +240,19 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
|
||||
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
|
||||
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| {
|
||||
@@ -245,6 +261,11 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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);
|
||||
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
|
||||
.into_iter()
|
||||
@@ -257,7 +278,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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, r.end.col))
|
||||
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();
|
||||
@@ -296,6 +317,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
});
|
||||
cx.structural = true;
|
||||
Ok(json!({"table": name, "range": r.a1()}))
|
||||
@@ -355,7 +377,12 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
"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),
|
||||
"header" => {
|
||||
t.filter_button = on.unwrap_or(!t.filter_button);
|
||||
if !t.filter_button {
|
||||
t.filter.clear();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
@@ -553,6 +580,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -567,12 +595,24 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -661,10 +701,11 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let data_w = r.width();
|
||||
let by_rows = data_w > data_h + 1;
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
let mut series = series_from_range(sh, r, by_rows);
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
scatter_x_from_first(kind, &mut series);
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
|
||||
let title = str_param(p, "title").map(str::to_string).or_else(|| {
|
||||
@@ -801,6 +842,7 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"freeze": sh.freeze,
|
||||
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
|
||||
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"images": sh.images.len(),
|
||||
"shapes": sh.shapes.len(),
|
||||
"conditionalFormats": sh.cond_formats.len(),
|
||||
|
||||
@@ -149,7 +149,42 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
}
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
if let Some(spec) = command_specs().iter().find(|c| c.id == id) {
|
||||
return Some(spec);
|
||||
}
|
||||
// Commands registered by UI integration tests (pending keytips go live
|
||||
// the moment their command lands, with no table edit).
|
||||
test_commands().iter().find(|c| c.id == id).copied()
|
||||
}
|
||||
|
||||
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).
|
||||
/// Not part of the public API; harmless no-op for production callers who never call it.
|
||||
#[doc(hidden)]
|
||||
pub fn register_test_command(id: &'static str, run: Run) {
|
||||
// Leaked intentionally: test commands live for the whole process.
|
||||
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
|
||||
id,
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
journal: true,
|
||||
undoable: false,
|
||||
}));
|
||||
test_commands().push(spec);
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- param helpers
|
||||
@@ -302,6 +337,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||||
}
|
||||
if !structural {
|
||||
check_arrays(&d.wb, &wb)?;
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
}
|
||||
@@ -372,6 +410,36 @@ pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocke
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuses an edit that put something into a cell of a multi-cell legacy array other than its
|
||||
/// formula cell, while the array stays (Excel: "You can't change part of an array."). The array
|
||||
/// would show its value there while the file kept the new content. Cells shifted by row and
|
||||
/// column edits aren't edits of their content, so structural commits aren't checked.
|
||||
fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if sh.spill.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
|
||||
if std::sync::Arc::ptr_eq(sh, after) {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||||
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||||
continue;
|
||||
}
|
||||
let in_array = sh.spill_ranges.keys().any(|&anchor| {
|
||||
anchor != c
|
||||
&& after.cell(anchor).and_then(|x| x.formula.as_ref()).and_then(|f| f.array).is_some_and(|a| a.start == anchor && a.contains(c))
|
||||
});
|
||||
if in_array {
|
||||
return Err(EngineError::Other("You can't change part of an array.".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
|
||||
@@ -167,27 +167,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
||||
for t in sh.tables.iter_mut() {
|
||||
if let Some(r) = map_range(t.range) {
|
||||
// Columns inserted inside a table become new table columns.
|
||||
if axis == Axis::Cols && insert && at > t.range.start.col && at <= t.range.end.col {
|
||||
let idx = (at - t.range.start.col) as usize;
|
||||
for k in 0..count {
|
||||
let name = format!("Column{}", t.columns.len() + 1);
|
||||
t.columns.insert(
|
||||
(idx + k as usize).min(t.columns.len()),
|
||||
gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None },
|
||||
);
|
||||
}
|
||||
}
|
||||
if axis == Axis::Cols && !insert {
|
||||
let lo = at.max(t.range.start.col);
|
||||
let hi = (at + count).min(t.range.end.col + 1);
|
||||
if hi > lo {
|
||||
let a = (lo - t.range.start.col) as usize;
|
||||
let b = (hi - t.range.start.col) as usize;
|
||||
if b <= t.columns.len() {
|
||||
t.columns.drain(a..b);
|
||||
}
|
||||
}
|
||||
if axis == Axis::Cols {
|
||||
shift_table_columns(t, at, count, insert);
|
||||
}
|
||||
t.range = r;
|
||||
} else {
|
||||
@@ -444,6 +425,9 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
|
||||
check_table_shift(sh, &e)?;
|
||||
}
|
||||
let right = shift == "right";
|
||||
let out = edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -472,6 +456,9 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
|
||||
check_table_shift(sh, &e)?;
|
||||
}
|
||||
let left = shift == "left";
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
@@ -798,8 +785,16 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if copy {
|
||||
let mut sh = (*src).clone();
|
||||
let base = sh.name.clone();
|
||||
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
|
||||
sh.name = name.chars().take(31).collect();
|
||||
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
|
||||
let name = (2..)
|
||||
.map(|k| {
|
||||
let suffix = format!(" ({k})");
|
||||
let keep = 31usize.saturating_sub(suffix.chars().count());
|
||||
format!("{}{suffix}", base.chars().take(keep).collect::<String>().trim_end())
|
||||
})
|
||||
.find(|nm| cx.wb.sheet_index(nm).is_none())
|
||||
.unwrap_or(base.clone());
|
||||
sh.name = name;
|
||||
sh.tables.clear(); // table names must be unique
|
||||
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
|
||||
let at = to.min(n);
|
||||
@@ -910,7 +905,7 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
|
||||
}
|
||||
|
||||
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
|
||||
fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||
pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
||||
sheet: gridcraft_formula::SheetSel::Current,
|
||||
kind: gridcraft_formula::RefKind::Range(
|
||||
@@ -942,9 +937,16 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
}
|
||||
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
||||
for t in sh.tables.iter_mut() {
|
||||
// Cells inserted or deleted across a table's full height add or remove table columns.
|
||||
if let Edit::InsertCells { axis: Axis::Cols, range } | Edit::DeleteCells { axis: Axis::Cols, range } = *e
|
||||
&& range.start.row <= t.range.start.row
|
||||
&& range.end.row >= t.range.end.row
|
||||
{
|
||||
shift_table_columns(t, range.start.col, range.width(), matches!(e, Edit::InsertCells { .. }));
|
||||
}
|
||||
t.range = map_range(t.range, e).unwrap_or_default();
|
||||
}
|
||||
sh.tables.retain(|t| t.range != RangeRef::default());
|
||||
sh.tables.retain(|t| t.range != RangeRef::default() && !t.columns.is_empty());
|
||||
if let Some(af) = sh.autofilter.as_mut() {
|
||||
match map_range(af.range, e) {
|
||||
Some(r) => af.range = r,
|
||||
@@ -964,3 +966,64 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
sh.print.print_area = map_range(pa, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns inserted inside a table become new table columns (with names no other column has);
|
||||
/// deleted ones leave it.
|
||||
fn shift_table_columns(t: &mut gridcraft_model::Table, at: u32, count: u32, insert: bool) {
|
||||
if insert {
|
||||
if at > t.range.start.col && at <= t.range.end.col {
|
||||
let idx = (at - t.range.start.col) as usize;
|
||||
let mut n = t.columns.len();
|
||||
for k in 0..count as usize {
|
||||
let name = loop {
|
||||
n += 1;
|
||||
let name = format!("Column{n}");
|
||||
if !t.columns.iter().any(|c| c.name.eq_ignore_ascii_case(&name)) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
let col = gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None };
|
||||
t.columns.insert((idx + k).min(t.columns.len()), col);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let lo = at.max(t.range.start.col);
|
||||
let hi = at.saturating_add(count).min(t.range.end.col + 1);
|
||||
if hi > lo {
|
||||
let a = (lo - t.range.start.col) as usize;
|
||||
let b = (hi - t.range.start.col) as usize;
|
||||
if b <= t.columns.len() {
|
||||
t.columns.drain(a..b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Like Excel, Insert/Delete Cells never shifts part of a table: cells shifted up or down must
|
||||
/// hold whole table columns, cells shifted left or right whole table rows, and deleting cells
|
||||
/// never takes a table's header row without the whole table.
|
||||
pub(crate) fn check_table_shift(sh: &Sheet, e: &Edit) -> Result<()> {
|
||||
let (axis, r, delete) = match *e {
|
||||
Edit::InsertCells { axis, range } => (axis, range, false),
|
||||
Edit::DeleteCells { axis, range } => (axis, range, true),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
for t in &sh.tables {
|
||||
let tr = t.range;
|
||||
let ok = match axis {
|
||||
Axis::Rows => {
|
||||
let moves = tr.end.row >= r.start.row && tr.start.col <= r.end.col && tr.end.col >= r.start.col;
|
||||
let header_alone = delete && t.header_row && r.start.row <= tr.start.row && r.end.row >= tr.start.row && r.end.row < tr.end.row;
|
||||
!moves || (r.start.col <= tr.start.col && r.end.col >= tr.end.col && !header_alone)
|
||||
}
|
||||
Axis::Cols => {
|
||||
let moves = tr.end.col >= r.start.col && tr.start.row <= r.end.row && tr.end.row >= r.start.row;
|
||||
!moves || (r.start.row <= tr.start.row && r.end.row >= tr.end.row)
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -80,15 +80,20 @@ pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
||||
|
||||
/// The text a cell shows (full precision General; the grid narrows General to fit the column).
|
||||
pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let v = sheet.value(c);
|
||||
if sheet.show_formulas
|
||||
&& let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
|
||||
&& !formula_hidden(wb, sheet, c)
|
||||
{
|
||||
return format!("={}", f.text);
|
||||
}
|
||||
value_text(wb, sheet, c)
|
||||
}
|
||||
|
||||
/// A cell's value with its number format applied, independent of column width: no `#####`
|
||||
/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result.
|
||||
pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let style = wb.styles.get(sheet.style_id(c));
|
||||
let f = format(&v, style.num_fmt.as_str(), wb);
|
||||
let f = format(&sheet.value(c), style.num_fmt.as_str(), wb);
|
||||
match f.fill {
|
||||
Some((_, pos)) => {
|
||||
let mut t = f.text;
|
||||
|
||||
+202
-19
@@ -38,16 +38,56 @@ impl FileKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for an encrypted workbook opened without a password contains this. The interface
|
||||
/// asks for the password when it sees it; programmatic callers pass `password` instead.
|
||||
pub const PASSWORD_REQUIRED: &str = "is password-protected. Enter its password to open it.";
|
||||
/// The error for a wrong password contains this.
|
||||
pub const WRONG_PASSWORD: &str = "The password is incorrect.";
|
||||
/// The first open warning of a workbook decrypted with its password.
|
||||
pub const DECRYPTED_NOTICE: &str = "This workbook was password-protected. GridCraft saves it without the password.";
|
||||
|
||||
/// Parses a file's bytes into a workbook. `name` picks the format by extension when the bytes
|
||||
/// don't say.
|
||||
/// don't say. An encrypted workbook needs [`open_bytes_with_password`].
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
open_bytes_with_password(name, bytes, None)
|
||||
}
|
||||
|
||||
/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never
|
||||
/// quote the password.
|
||||
pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec<String>)> {
|
||||
if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted {
|
||||
return open_plain(name, bytes);
|
||||
}
|
||||
// A scheme no password opens is said straight away, rather than after asking for one.
|
||||
let decrypt_error = |e: gridcraft_xlsx::DecryptError| EngineError::Other(decrypt_message(name, e));
|
||||
let Some(password) = password else {
|
||||
gridcraft_xlsx::check_encryption(bytes).map_err(decrypt_error)?;
|
||||
return Err(EngineError::Other(format!("'{name}' {PASSWORD_REQUIRED}")));
|
||||
};
|
||||
let plain = gridcraft_xlsx::decrypt(bytes, password).map_err(decrypt_error)?;
|
||||
if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
|
||||
}
|
||||
let (wb, mut warnings) = open_plain(name, &plain)?;
|
||||
warnings.insert(0, DECRYPTED_NOTICE.into());
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// What to tell the user when an encrypted workbook can't be decrypted. Never quotes the password.
|
||||
fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
match e {
|
||||
gridcraft_xlsx::DecryptError::WrongPassword => format!("{WRONG_PASSWORD} Check that Caps Lock is off and try again."),
|
||||
gridcraft_xlsx::DecryptError::Unsupported(scheme) => {
|
||||
format!("'{name}' uses {scheme} encryption, which GridCraft can't open yet. Remove the password in Excel and try again.")
|
||||
}
|
||||
gridcraft_xlsx::DecryptError::Malformed(detail) => format!("We can't open '{name}': the encrypted file is damaged ({detail})."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted.
|
||||
fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
let kind = FileKind::from_path(name);
|
||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||
if sniffed == gridcraft_xlsx::Format::Encrypted {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
|
||||
)));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::Xls {
|
||||
let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
@@ -85,6 +125,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() };
|
||||
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
|
||||
wb.csv_delimiter = None;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, vec![]))
|
||||
}
|
||||
@@ -113,26 +155,40 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
let sheet = wb.active_sheet;
|
||||
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
|
||||
// A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm
|
||||
// keeps the macros; any other name saves without them, as Excel does.
|
||||
FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => {
|
||||
let mut plain = wb.clone();
|
||||
plain.vba_project = None;
|
||||
gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Xls => {
|
||||
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
|
||||
}
|
||||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
|
||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
|
||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()),
|
||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Html => Ok(to_html(wb, sheet).into_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
|
||||
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
|
||||
/// transferred to other file formats*): number formats applied, never column-width `#####`.
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
||||
gridcraft_xlsx::write_csv(sh, delimiter, |c| {
|
||||
// General's grid-width formatting rounds large identifiers to scientific notation.
|
||||
// Delimited files have no column width, so preserve the whole numeric value instead.
|
||||
if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General")
|
||||
&& let gridcraft_core::Value::Number(number) = sh.value(c)
|
||||
&& number.is_finite()
|
||||
{
|
||||
number.to_string()
|
||||
} else {
|
||||
crate::display::value_text(wb, sh, c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -384,6 +440,8 @@ mod tests {
|
||||
assert!(has_macros("book.xlsm"));
|
||||
assert!(has_macros("BOOK.XLSM"));
|
||||
assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project");
|
||||
assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project");
|
||||
assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project");
|
||||
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
|
||||
}
|
||||
|
||||
@@ -410,12 +468,71 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
/// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last
|
||||
/// two columns, a part name containing a comma.
|
||||
const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\
|
||||
1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n";
|
||||
|
||||
#[test]
|
||||
fn csv_save_keeps_the_separator_it_was_opened_with() {
|
||||
let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap();
|
||||
assert_eq!(wb.csv_delimiter, Some(b';'));
|
||||
assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST);
|
||||
// Save As another .csv name keeps it too; a comma file stays a comma file (quoting only
|
||||
// fields that contain the separator in use).
|
||||
assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST);
|
||||
let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n";
|
||||
let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap();
|
||||
assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas);
|
||||
// A tab-separated file named .csv round-trips with tabs.
|
||||
let tabs = "a\tb\r\n1\t2\r\n";
|
||||
let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap();
|
||||
assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() {
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a"));
|
||||
sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0));
|
||||
}
|
||||
assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n");
|
||||
// Through .xlsx the CSV origin is gone, as in Excel.
|
||||
let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap();
|
||||
let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap();
|
||||
assert_eq!(reopened.csv_delimiter, None);
|
||||
assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,"));
|
||||
// A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs.
|
||||
let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap();
|
||||
assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n");
|
||||
assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_workbook_is_reported_not_misread() {
|
||||
let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("password-protected"), "got: {msg}");
|
||||
let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw");
|
||||
let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string();
|
||||
assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}");
|
||||
assert!(!msg.contains("zip"), "must not surface the zip error: {msg}");
|
||||
// A compound file naming the streams but unreadable is damage: no password is asked for,
|
||||
// and none helps.
|
||||
let file = cfb(&["EncryptionInfo", "EncryptedPackage"]);
|
||||
for password in [None, Some("pw")] {
|
||||
let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string();
|
||||
assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encryption_no_password_opens_is_reported_without_asking_for_one() {
|
||||
let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES
|
||||
let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]);
|
||||
let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string();
|
||||
assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}");
|
||||
let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]);
|
||||
let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string();
|
||||
assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -425,4 +542,70 @@ mod tests {
|
||||
assert!(msg.contains("not an Excel workbook"), "got: {msg}");
|
||||
assert!(!msg.contains("password"), "got: {msg}");
|
||||
}
|
||||
|
||||
/// Save As CSV/TSV writes cells as displayed: number formats applied, formula results,
|
||||
/// whole values in narrow columns (never `#####`), quoting unchanged.
|
||||
#[test]
|
||||
fn csv_writes_displayed_text() {
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId};
|
||||
let mut wb = Workbook::new();
|
||||
let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code));
|
||||
let (date, money, pct, frac, text, fixed, thousands) =
|
||||
(styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0"));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cells = [
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }),
|
||||
("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }),
|
||||
];
|
||||
for (a, c) in cells {
|
||||
sh.set_cell(CellRef::parse(a).unwrap(), c);
|
||||
}
|
||||
// Column B is far too narrow for its numbers: the grid shows #####, the file must not.
|
||||
sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() });
|
||||
sh.show_formulas = true;
|
||||
let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap();
|
||||
assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n");
|
||||
let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap();
|
||||
assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimited_exports_preserve_general_precision_and_explicit_number_formats() {
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_model::{Cell, NumFmt, StyleId};
|
||||
let mut workbook = Workbook::new();
|
||||
let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00"));
|
||||
let sheet = workbook.sheet_mut(0).unwrap();
|
||||
sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() });
|
||||
sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() });
|
||||
sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() });
|
||||
for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] {
|
||||
let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap();
|
||||
assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_extension_saves_template_content_type() {
|
||||
let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut types = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap();
|
||||
assert!(
|
||||
types.contains(
|
||||
r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""#
|
||||
),
|
||||
"{types}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use gridcraft_model::Workbook;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_test_command};
|
||||
pub use gridcraft_calc as calc;
|
||||
pub use gridcraft_core as core;
|
||||
pub use gridcraft_formula as formula;
|
||||
|
||||
@@ -1397,6 +1397,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -109,6 +109,7 @@ fn sales(s: &mut Session) -> bool {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +176,15 @@ pub(crate) fn sync_headers(cx: &mut crate::cmd::Ctx) {
|
||||
let Some(t) = sh.tables.get(ti) else { continue };
|
||||
let col = (c.col - t.range.start.col) as usize;
|
||||
let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
|
||||
let text = crate::display::cell_text(&cx.wb, sh, c);
|
||||
let is_text = sh.value(c).is_text();
|
||||
// A text header names its column as it is: a number format only changes how it looks (an
|
||||
// accounting format pads text with spaces), and the file writes the name into the cell.
|
||||
let value = sh.value(c);
|
||||
let text = match value.as_text() {
|
||||
Some(t) => t.to_string(),
|
||||
None => crate::display::cell_text(&cx.wb, sh, c),
|
||||
};
|
||||
// Headers hold constant text: a formula (or a spilled value) there becomes its text.
|
||||
let is_text = value.is_text() && sh.cell(c).is_some_and(|x| x.formula.is_none());
|
||||
if text == old && is_text {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,45 @@ fn enter_values_and_formulas() {
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "12%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_with_the_wrong_argument_count_are_refused_on_entry() {
|
||||
// #363: =DATEDIF(start, end) was stored and showed #VALUE!. Like Excel, entry refuses it and
|
||||
// the message gives the signature, so the missing unit argument is visible.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "kept"})).unwrap();
|
||||
for (input, word) in [
|
||||
("=DATEDIF(DATE(2024,1,1),DATE(2024,3,15))", "few"),
|
||||
("=1+DATE(2024,1)", "few"),
|
||||
("=SUM()", "few"),
|
||||
("=IFERROR(1,LEFT(\"a\",1,2,3))", "many"),
|
||||
("=datedif(1,2,\"D\",4)", "many"),
|
||||
] {
|
||||
let e = s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("too {word} arguments")), "{input}: {e}");
|
||||
assert_eq!(v(&s, "A1"), Value::text("kept"), "{input} must not replace the cell");
|
||||
}
|
||||
let e = s.execute("cell.set", json!({"cell": "A1", "input": "=DATEDIF(1,2)"})).unwrap_err().to_string();
|
||||
assert!(e.contains("DATEDIF(start_date, end_date, unit)"), "{e}");
|
||||
// Correct counts, optional arguments left empty, special forms, LAMBDA names and unknown
|
||||
// functions (#NAME?) still enter.
|
||||
s.execute("formulas.defineName", json!({"name": "TWICE", "refersTo": "=LAMBDA(x,x*2)"})).unwrap();
|
||||
for (cell, input) in [
|
||||
("B1", "=DATEDIF(DATE(2024,1,1),DATE(2024,3,15),\"D\")"),
|
||||
("B2", "=IF(TRUE,1)"),
|
||||
("B3", "=LET(a,1,b,2,a+b)"),
|
||||
("B4", "=TWICE(4)"),
|
||||
("B5", "=NOSUCHFUNCTION(1,2,3)"),
|
||||
("B6", "=ROUND(2.5,)"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap_or_else(|e| panic!("{input}: {e}"));
|
||||
}
|
||||
assert_eq!(v(&s, "B1"), Value::Number(74.0));
|
||||
assert_eq!(v(&s, "B4"), Value::Number(8.0));
|
||||
// Pasted text that isn't a valid formula stays text, as it does for a parse error.
|
||||
s.execute("edit.paste", json!({"at": "C1", "text": "=DATEDIF(1,2)\n"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::text("=DATEDIF(1,2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo() {
|
||||
let mut s = s();
|
||||
@@ -1032,6 +1071,202 @@ fn slicer_filters_table() {
|
||||
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_header_formatted_as_accounting_keeps_its_text_as_the_column_name() {
|
||||
// Found by the command-sequence fuzzer: the accounting format pads text (` 172.4 `), and that
|
||||
// padded text became the column name, which the XLSX writer then put in the header cell.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[172.4, "Qty"], [1, 2]]})).unwrap();
|
||||
s.execute("home.accounting", json!({"range": "A1:B2"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B2", "header": true})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let t = &d.wb.sheet(0).unwrap().tables[0];
|
||||
let names: Vec<_> = t.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["172.4", "Qty"]);
|
||||
assert_eq!(v(&s, "A1"), Value::text("172.4"));
|
||||
reopen(&mut s);
|
||||
assert_eq!(v(&s, "A1"), Value::text("172.4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_moved_into_a_table_header_becomes_its_text() {
|
||||
// Found by the command-sequence fuzzer: deleting a header row moved a formula into the
|
||||
// header, which kept it while the XLSX writer wrote the column name there instead.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty"], ["x", "=1+1"], ["y", 5]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.cell(CellRef::new(0, 1)).and_then(|x| x.formula.clone()), None);
|
||||
assert_eq!(v(&s, "B1"), Value::text("2"));
|
||||
let names: Vec<_> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["x", "2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_column_table_without_headers_stays_on_the_sheet() {
|
||||
// Found by the command-sequence fuzzer: the inserted header row pushed the table's end past
|
||||
// the last row, so the table had an invalid range and was dropped from the XLSX file.
|
||||
let mut s = s();
|
||||
s.execute("insert.table", json!({"range": "A:C", "header": false})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.sheet(0).unwrap().tables[0].range.a1(), "A:C");
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "=ROWS(Table1)"})).unwrap();
|
||||
assert_eq!(v(&s, "E1"), Value::Number(1_048_575.0));
|
||||
reopen(&mut s);
|
||||
assert_eq!(v(&s, "E1"), Value::Number(1_048_575.0));
|
||||
s.execute("cell.set", json!({"cell": "F1048576", "input": "x"})).unwrap();
|
||||
let e = s.execute("insert.table", json!({"range": "F:F", "header": false})).unwrap_err();
|
||||
assert!(e.to_string().contains("pushed off the end"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
|
||||
// Found by the command-sequence fuzzer: AutoSum over rows `3:5` ran one `cell.set` per column
|
||||
// (16,384 undo steps, so undo never got back to the start).
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A3", "values": [[1, 2], [3, 4], [5, 6]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "3:5"})).unwrap();
|
||||
assert_eq!(v(&s, "A6"), Value::Number(9.0));
|
||||
assert_eq!(v(&s, "B6"), Value::Number(12.0));
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.undo.len(), 2);
|
||||
assert_eq!(d.wb.active().unwrap().cells.used_range().unwrap().a1(), "A3:B6");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A6"), Value::Empty);
|
||||
assert_eq!(v(&s, "B6"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_delete_cells_never_shift_part_of_a_table() {
|
||||
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
|
||||
// tables overlapped) or narrowed a table without dropping its columns, and a table without
|
||||
// headers pushed the cells of another table down from under it.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "D8", "values": [["a", "b", "c"], [1, 2, 3], [4, 5, 6]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "D8:F10", "header": true})).unwrap();
|
||||
for (cmd, range, shift) in [
|
||||
("home.deleteCells", "E9:G9", "left"),
|
||||
("home.deleteCells", "E9", "up"),
|
||||
("home.insertCells", "C9:D9", "down"),
|
||||
("home.deleteCells", "D8:F8", "up"),
|
||||
("home.insertCells", "A9:B9", "right"),
|
||||
] {
|
||||
let e = s.execute(cmd, json!({"range": range, "shift": shift})).unwrap_err();
|
||||
assert!(e.to_string().contains("shift cells in a table"), "{cmd} {range} {shift}: {e}");
|
||||
}
|
||||
// Whole table columns: the table loses (or gains) columns with them.
|
||||
s.execute("home.deleteCells", json!({"range": "E7:E12", "shift": "left"})).unwrap();
|
||||
let names = |s: &Session| -> Vec<String> { s.doc().unwrap().wb.sheet(0).unwrap().tables[0].columns.iter().map(|c| c.name.clone()).collect() };
|
||||
assert_eq!(names(&s), ["a", "c"]);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().tables[0].range.a1(), "D8:E10");
|
||||
s.execute("home.insertCells", json!({"range": "E8:E10", "shift": "right"})).unwrap();
|
||||
assert_eq!(names(&s), ["a", "Column2", "c"]);
|
||||
assert_eq!(v(&s, "E8"), Value::text("Column2"));
|
||||
let e = s.execute("insert.table", json!({"range": "H1:H2", "name": "T1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "a cell reference isn't a table name: {e}");
|
||||
let e = s.execute("insert.table", json!({"range": "D5:F6", "header": false})).unwrap_err();
|
||||
assert!(e.to_string().contains("shift cells in a table"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_keep_the_watch_window_list() {
|
||||
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
|
||||
// undoing an earlier edit swapped in a snapshot without it.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("formulas.watchWindow", json!({"add": "Sheet1!A1"})).unwrap();
|
||||
let watched = |s: &mut Session| -> Vec<String> {
|
||||
let rows = s.execute("formulas.watchWindow", json!({})).unwrap();
|
||||
rows.as_array().unwrap().iter().map(|r| r["cell"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(watched(&mut s), ["Sheet1!A1"], "kept after undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(watched(&mut s), ["Sheet1!A1"], "kept after redo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_over_a_spill_blocks_it_and_unmerging_unblocks_it() {
|
||||
// Found by the command-sequence fuzzer: merges changed no cell the spill depended on, so the
|
||||
// spill went on as if the merged cells were free (a full recalc gave #SPILL!).
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("home.mergeCenter", json!({"range": "A2:B3"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill));
|
||||
s.execute("home.mergeCenter", json!({"range": "A2:B3"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_cell_array_formulas_and_tables_dont_overlap() {
|
||||
// Found by the command-sequence fuzzer: an array over a table header lost its value to the
|
||||
// header name written as text, then showed #SPILL! (and reloaded from XLSX differently).
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [["Item", "Qty"], ["x", 2]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "B2:C3", "header": true})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
let e = s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("aren't allowed in tables"), "{e}");
|
||||
assert_eq!(v(&s, "B2"), Value::text("Item"));
|
||||
s.execute("selection.set", json!({"range": "E1:E2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
|
||||
let e = s.execute("insert.table", json!({"range": "E2:F3"})).unwrap_err();
|
||||
assert!(e.to_string().contains("aren't allowed in tables"), "{e}");
|
||||
// One-cell arrays are fine.
|
||||
s.execute("selection.set", json!({"range": "C3"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||
assert_eq!(v(&s, "C3"), Value::Number(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_of_a_long_named_sheet_get_unique_names() {
|
||||
// Found by the command-sequence fuzzer: `name (2)` was cut to 31 characters, so the second
|
||||
// copy of a 30-character name took the first copy's name.
|
||||
let mut s = s();
|
||||
s.execute("sheet.rename", json!({"sheet": 0, "name": "Long name of a sheet 31 chars!"})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 2, "copy": true})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["Long name of a sheet 31 chars!", "Long name of a sheet 31 cha (2)", "Long name of a sheet 31 cha (3)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_and_column_inserts_move_legacy_array_ranges() {
|
||||
// Found by the command-sequence fuzzer: the formula cell moved but its array range stayed,
|
||||
// so the array covered the wrong cells (and read back from XLSX as #SPILL!).
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C3:D3"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=$A$1*2", "array": true})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "B:B"})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let f = d.wb.sheet(0).unwrap().cell(CellRef::new(3, 3)).and_then(|x| x.formula.clone()).unwrap();
|
||||
assert_eq!(f.array, Some(gridcraft_core::RangeRef::parse("D4:E4").unwrap()));
|
||||
assert_eq!(v(&s, "E4"), Value::Number(14.0));
|
||||
reopen(&mut s);
|
||||
assert_eq!(v(&s, "E4"), Value::Number(14.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_of_a_legacy_array_cant_be_changed() {
|
||||
// Found by the command-sequence fuzzer: a value typed into an array cell showed in the
|
||||
// workbook, but the file kept the array there (Excel refuses the edit).
|
||||
let mut s = s();
|
||||
s.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||
let e = s.execute("range.setValues", json!({"range": "A2", "values": [[13]]})).unwrap_err();
|
||||
assert!(e.to_string().contains("part of an array"), "{e}");
|
||||
assert_eq!(v(&s, "A2"), Value::Number(5.0));
|
||||
// Replacing the whole array is fine.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_names_from_selection() {
|
||||
let names = |s: &Session| -> Vec<(String, String)> {
|
||||
@@ -1257,6 +1492,33 @@ fn ods_import_never_reuses_source_as_save_target() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Open a `;` CSV by path (a double-click), edit, Save: the file keeps its `;` separator.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn saving_an_opened_semicolon_csv_keeps_semicolons() {
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-csv-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let path = dir.join("cut list.csv");
|
||||
std::fs::write(&path, "1;511;184;P3_L1_led;2162PE;16;;1244121406FB\r\n1;562;510;P3_pod;2162PE;19;;124412140710\r\n").unwrap();
|
||||
let mut s = s();
|
||||
s.execute("file.open", json!({"path": path})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "600"})).unwrap();
|
||||
s.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"1;511;184;P3_L1_led;2162PE;16;;1244121406FB\r\n1;600;510;P3_pod;2162PE;19;;124412140710\r\n"
|
||||
);
|
||||
let exported = dir.join("export.csv");
|
||||
s.execute("file.exportCsv", json!({"path": exported})).unwrap();
|
||||
assert!(std::fs::read_to_string(&exported).unwrap().starts_with("1;511;184;"));
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
|
||||
let bytes = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"1;511;184;"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_formulas_are_relative_and_see_the_new_value() {
|
||||
let mut s = s();
|
||||
@@ -1508,6 +1770,44 @@ fn reopen(s: &mut Session) {
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_options_enable_iterative_calculation() {
|
||||
let mut s = s();
|
||||
let near = |s: &Session, a: &str, want: f64| {
|
||||
let got = v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
assert!((got - want).abs() < 0.002, "{a} = {:?}", v(s, a));
|
||||
};
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=(A2+10)/2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
|
||||
assert!(!s.doc().unwrap().calc.circular.is_empty());
|
||||
// Switching it on recalculates the loop.
|
||||
let r = s.execute("formulas.calculationOptions", json!({"iterative": true})).unwrap();
|
||||
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001}));
|
||||
near(&s, "A1", 10.0);
|
||||
near(&s, "A2", 10.0);
|
||||
assert!(s.doc().unwrap().calc.circular.is_empty());
|
||||
// Empty params change nothing and report the settings.
|
||||
let r = s.execute("formulas.calculationOptions", json!({})).unwrap();
|
||||
assert_eq!(r["iterative"], true);
|
||||
// Limits are kept within Excel's: 1–32767 iterations, a non-negative change.
|
||||
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 0, "maxChange": -0.5})).unwrap();
|
||||
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(1), Some(0.5)));
|
||||
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 100000, "maxChange": 0.0001})).unwrap();
|
||||
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(32767), Some(0.0001)));
|
||||
// Saved with the workbook.
|
||||
reopen(&mut s);
|
||||
let calc = s.doc().unwrap().wb.calc.clone();
|
||||
assert_eq!((calc.iterative, calc.max_iterations, calc.max_change), (true, 32767, 0.0001));
|
||||
near(&s, "A1", 10.0);
|
||||
// Undo brings the previous settings back.
|
||||
s.execute("formulas.calculationOptions", json!({"iterative": false})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.calc.iterative);
|
||||
near(&s, "A1", 10.0);
|
||||
}
|
||||
|
||||
fn chart_data(s: &mut Session) {
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
@@ -1554,6 +1854,31 @@ fn every_chart_kind_survives_xlsx() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_of_two_columns_is_one_series_with_the_first_as_x() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["X", "Y"], [1, 10], [2, 20], [3, 30]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"type": "scatter", "range": "A1:B4"})).unwrap();
|
||||
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
assert_eq!(c.series.len(), 1);
|
||||
assert_eq!(c.series[0].values, "Sheet1!$B$2:$B$4");
|
||||
assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
|
||||
// Select Data on the scatter chart and Switch Row/Column rebuild the series the same way.
|
||||
s.execute("chart.selectData", json!({"range": "A2:B4"})).unwrap();
|
||||
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
assert_eq!(c.series.len(), 1);
|
||||
assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2, 3, 4], [10, 20, 30, 40]]})).unwrap();
|
||||
s.execute("chart.selectData", json!({"range": "D1:G2"})).unwrap();
|
||||
s.execute("chart.switchRowColumn", json!({})).unwrap();
|
||||
s.execute("chart.switchRowColumn", json!({})).unwrap();
|
||||
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
assert!(c.by_rows);
|
||||
assert_eq!(c.series.len(), 1);
|
||||
assert_eq!(c.series[0].values, "Sheet1!$D$2:$G$2");
|
||||
assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$D$1:$G$1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_sheet_selection_stays_within_the_data() {
|
||||
// On an empty sheet with everything selected these filled or scanned every cell of the
|
||||
@@ -1608,6 +1933,45 @@ fn switch_row_column_on_a_chart_read_from_a_file() {
|
||||
assert_eq!(chart(&s).series.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_trendlines_are_added_saved_and_removed() {
|
||||
use gridcraft_model::TrendlineKind;
|
||||
let mut s = s();
|
||||
chart_data(&mut s);
|
||||
s.execute("insert.chart", json!({"type": "line", "range": "A1:E7"})).unwrap();
|
||||
let chart = |s: &Session| s.doc().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
s.execute("chart.addElement", json!({"element": "trendline", "series": 1, "type": "linearForecast", "displayEquation": true})).unwrap();
|
||||
s.execute("chart.addElement", json!({"element": "trendline", "series": 1, "type": "polynomial", "order": 9})).unwrap();
|
||||
s.execute("chart.addElement", json!({"element": "trendline", "series": 3, "type": "movingAverage", "period": 3})).unwrap();
|
||||
let c = chart(&s);
|
||||
assert!(c.series[0].trendlines.is_empty());
|
||||
let t = &c.series[1].trendlines;
|
||||
assert_eq!((t.len(), t[0].kind, t[0].forward, t[0].display_equation), (2, TrendlineKind::Linear, 2.0, true));
|
||||
assert_eq!(t[1].kind, TrendlineKind::Polynomial(6), "order clamps to Excel's 2..6");
|
||||
assert_eq!(c.series[3].trendlines[0].kind, TrendlineKind::MovingAverage(3));
|
||||
let info = s.execute("document.inspect", json!({})).unwrap();
|
||||
assert_eq!(info["sheets"][0]["charts"][0]["trendlines"], json!(["Linear", "Polynomial(6)", "MovingAverage(3)"]));
|
||||
assert!(s.execute("chart.addElement", json!({"element": "trendline", "series": 9})).is_err());
|
||||
assert!(s.execute("chart.addElement", json!({"element": "trendline", "type": "cubic"})).is_err());
|
||||
reopen(&mut s);
|
||||
let after = chart(&s);
|
||||
assert_eq!(after.series[1].trendlines, c.series[1].trendlines);
|
||||
assert_eq!(after.series[3].trendlines, c.series[3].trendlines);
|
||||
s.execute("chart.addElement", json!({"element": "trendline", "on": false})).unwrap();
|
||||
assert!(chart(&s).series.iter().all(|se| se.trendlines.is_empty()));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(chart(&s).series[1].trendlines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trendlines_are_refused_on_charts_that_cannot_show_them() {
|
||||
let mut s = s();
|
||||
chart_data(&mut s);
|
||||
s.execute("insert.chart", json!({"type": "pie", "range": "A1:B7"})).unwrap();
|
||||
assert!(s.execute("chart.addElement", json!({"element": "trendline"})).is_err());
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().charts[0].series[0].trendlines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_row_column_with_whole_column_refs_is_bounded() {
|
||||
let mut s = s();
|
||||
@@ -2360,3 +2724,74 @@ fn editing_a_date_cell_shows_a_date_not_the_serial() {
|
||||
let d = s2.doc().unwrap();
|
||||
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42");
|
||||
}
|
||||
|
||||
/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `password`.
|
||||
fn encrypted_book(password: &str) -> Vec<u8> {
|
||||
let mut src = s();
|
||||
src.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
let xlsx = crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
gridcraft_xlsx::testing::encrypt_workbook(&xlsx, password)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_workbooks_open_with_their_password() {
|
||||
const PW: &str = "synthetic-pw-5521";
|
||||
let b64 = crate::io::base64_encode(&encrypted_book(PW));
|
||||
let open = |s: &mut Session, password: Option<&str>| {
|
||||
let mut p = json!({"name": "Secret.xlsx", "base64": b64});
|
||||
if let Some(pw) = password {
|
||||
p["password"] = json!(pw);
|
||||
}
|
||||
s.execute("file.open", p)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
|
||||
// Programmatic open without a password: an error carrying the marker, never a dialog.
|
||||
let err = open(&mut s, None).unwrap_err().to_string();
|
||||
assert!(err.contains(crate::io::PASSWORD_REQUIRED), "{err}");
|
||||
assert!(err.contains("Secret.xlsx"), "{err}");
|
||||
assert!(s.take_ui_requests().is_empty(), "programmatic calls never open dialogs");
|
||||
assert!(s.documents().is_empty());
|
||||
|
||||
// Wrong and empty passwords are "incorrect", and the password is never quoted.
|
||||
for wrong in ["not-it", ""] {
|
||||
let err = open(&mut s, Some(wrong)).unwrap_err().to_string();
|
||||
assert!(err.contains(crate::io::WRONG_PASSWORD), "{err}");
|
||||
assert!(wrong.is_empty() || !err.contains(wrong), "{err}");
|
||||
}
|
||||
assert!(s.documents().is_empty());
|
||||
|
||||
// The right password opens it: no save path, the file name as title, the notice first.
|
||||
let r = open(&mut s, Some(PW)).unwrap();
|
||||
assert_eq!(r["warnings"][0], crate::io::DECRYPTED_NOTICE);
|
||||
assert_eq!(r["title"], "Secret.xlsx");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(42.0));
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
// The notice belongs in the info bar, not a message box.
|
||||
assert!(!s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_))));
|
||||
let journal = serde_json::to_string(&s.journal).unwrap();
|
||||
assert!(!journal.contains(PW), "the password never reaches the journal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
|
||||
const PW: &str = "synthetic-pw-5522";
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let path = dir.join("Secret.xlsx");
|
||||
let bytes = encrypted_book(PW);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
|
||||
let mut s = s();
|
||||
s.execute("file.open", json!({"path": path, "password": PW})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
s.take_ui_requests();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap();
|
||||
s.execute("file.save", json!({})).unwrap();
|
||||
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs")));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn table() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values":[["Quarterly Revenue"], ["1"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range":"A1:A2", "header":true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn fit(s: &mut Session) -> f32 {
|
||||
s.execute("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
|
||||
s.doc().unwrap().wb.active().unwrap().col_width(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_reserves_room_for_table_filter_button() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell":"A1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
let plain = fit(&mut s);
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
let filtered = fit(&mut s);
|
||||
assert!(filtered >= plain + 16.0, "filter button obscures heading: plain={plain}, filtered={filtered}");
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)).display(), "Quarterly Revenue");
|
||||
}
|
||||
|
||||
fn sheet_mut(s: &mut Session) -> &mut gridcraft_engine::model::Sheet {
|
||||
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().wb).sheet_mut(0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_skips_disabled_buttons_and_hidden_table_headers() {
|
||||
let mut s = table();
|
||||
sheet_mut(&mut s).tables[0].filter_button = false;
|
||||
let plain = fit(&mut s);
|
||||
sheet_mut(&mut s).tables[0].filter_button = true;
|
||||
sheet_mut(&mut s).tables[0].header_row = false;
|
||||
assert_eq!(fit(&mut s), plain);
|
||||
sheet_mut(&mut s).tables[0].header_row = true;
|
||||
assert!(fit(&mut s) > plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_matches_excel_filter_allowance_across_zoom_and_row_heights() {
|
||||
for (zoom, allowance) in [(50, 12.5 * 96.0 / 72.0 * 2.0), (100, 12.5 * 96.0 / 72.0), (200, 12.5 * 96.0 / 72.0)] {
|
||||
for height in [12.0_f32, 20.0, 60.0] {
|
||||
let mut s = table();
|
||||
s.execute("view.zoom", json!({"percent":zoom})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows":"1:1", "height":height})).unwrap();
|
||||
sheet_mut(&mut s).tables[0].filter_button = false;
|
||||
let plain = fit(&mut s);
|
||||
sheet_mut(&mut s).tables[0].filter_button = true;
|
||||
let filtered = fit(&mut s);
|
||||
assert!((filtered - plain - allowance).abs() < 1.0, "zoom={zoom}, height={height}, plain={plain}, filtered={filtered}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_supports_ordinary_autofilters_without_double_counting_table_buttons() {
|
||||
let mut s = table();
|
||||
let table_width = fit(&mut s);
|
||||
sheet_mut(&mut s).autofilter =
|
||||
Some(gridcraft_engine::model::AutoFilter { range: gridcraft_engine::core::RangeRef::parse("A1:A2").unwrap(), criteria: vec![] });
|
||||
assert_eq!(fit(&mut s), table_width, "overlapping filters occupy one button slot");
|
||||
sheet_mut(&mut s).tables.clear();
|
||||
assert_eq!(fit(&mut s), table_width, "ordinary filter headers reserve the same space");
|
||||
sheet_mut(&mut s).autofilter = None;
|
||||
assert!(fit(&mut s) < table_width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_leaves_longer_body_cells_and_unfiltered_columns_unchanged() {
|
||||
let mut s = table();
|
||||
s.execute("cell.set", json!({"cell":"A2", "input":"A body value much longer than the short table header above"})).unwrap();
|
||||
let filtered = fit(&mut s);
|
||||
sheet_mut(&mut s).tables[0].filter_button = false;
|
||||
assert_eq!(fit(&mut s), filtered);
|
||||
s.execute("cell.set", json!({"cell":"B1", "input":"Quarterly Revenue"})).unwrap();
|
||||
s.execute("home.autofitColumnWidth", json!({"cols":"B:B"})).unwrap();
|
||||
let plain = s.doc().unwrap().wb.active().unwrap().col_width(1);
|
||||
sheet_mut(&mut s).tables[0].filter_button = true;
|
||||
s.execute("home.autofitColumnWidth", json!({"cols":"B:B"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_filter_width_is_undoable_without_changing_selection() {
|
||||
let mut s = table();
|
||||
s.execute("home.columnWidth", json!({"cols":"A:A", "width":80})).unwrap();
|
||||
s.execute("selection.set", json!({"range":"A:A"})).unwrap();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
s.active_mut().unwrap().undo.clear();
|
||||
fit(&mut s);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.doc().unwrap().undo.len(), 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 80.0);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,38 @@ pub enum UnOp {
|
||||
/// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the
|
||||
/// cell (`ANCHORARRAY` in files).
|
||||
Spill,
|
||||
/// Trim reference `A1.:E10` / `A1:.E10` / `A1.:.E10` around a range: the range without its
|
||||
/// blank leading, trailing or outer rows and columns (`TRIMRANGE` with both trim codes set).
|
||||
Trim(TrimMode),
|
||||
}
|
||||
|
||||
/// Which blank edges a trim reference drops.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum TrimMode {
|
||||
/// `.:` — leading blank rows and columns.
|
||||
Leading,
|
||||
/// `:.` — trailing blank rows and columns.
|
||||
Trailing,
|
||||
/// `.:.` — both.
|
||||
All,
|
||||
}
|
||||
|
||||
impl TrimMode {
|
||||
pub fn symbol(self) -> &'static str {
|
||||
match self {
|
||||
TrimMode::Leading => ".:",
|
||||
TrimMode::Trailing => ":.",
|
||||
TrimMode::All => ".:.",
|
||||
}
|
||||
}
|
||||
/// The equivalent `TRIMRANGE` trim_rows / trim_cols code.
|
||||
pub fn code(self) -> u8 {
|
||||
match self {
|
||||
TrimMode::Leading => 1,
|
||||
TrimMode::Trailing => 2,
|
||||
TrimMode::All => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
use gridcraft_core::CellError;
|
||||
|
||||
use crate::ast::TrimMode;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Tok {
|
||||
Number(f64),
|
||||
@@ -27,6 +29,8 @@ pub enum Tok {
|
||||
Comma,
|
||||
Semicolon,
|
||||
Colon,
|
||||
/// Trim reference range operator `.:`, `:.` or `.:.`.
|
||||
Trim(TrimMode),
|
||||
Space,
|
||||
Eof,
|
||||
}
|
||||
@@ -49,6 +53,20 @@ fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '.' | '$' | '\\' | '?')
|
||||
}
|
||||
|
||||
/// `A1`, `$A$1`, `A`, `$A`, `1`, `$1`: a word that can end a range, so `A1.:` is a trim
|
||||
/// reference and not a name ending in a dot.
|
||||
fn is_range_end(w: &str) -> bool {
|
||||
let s = w.strip_prefix('$').unwrap_or(w);
|
||||
let (letters, rest) = s.split_at(s.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(s.len()));
|
||||
let digits = if letters.is_empty() { rest } else { rest.strip_prefix('$').unwrap_or(rest) };
|
||||
letters.len() <= 3 && if digits.is_empty() { !letters.is_empty() && rest.is_empty() } else { digits.bytes().all(|b| b.is_ascii_digit()) }
|
||||
}
|
||||
|
||||
/// Whether `c` can start the second end of a range after `:.` (`A1:.E10`, `A:.$C`, `1:.5`).
|
||||
fn starts_range_end(c: Option<char>) -> bool {
|
||||
c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '$')
|
||||
}
|
||||
|
||||
/// Tokenizes formula text (without the leading `=`).
|
||||
pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
tokenize_locale(src, crate::FormulaLocale::En)
|
||||
@@ -178,6 +196,16 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
push(&mut out, Tok::Struct(inner), j);
|
||||
i = j;
|
||||
}
|
||||
'.' if ch(i + 1) == Some(':') => {
|
||||
// Trim reference `A1.:E10` / `A1.:.E10` (the word before stopped at the dot).
|
||||
if ch(i + 2) == Some('.') && starts_range_end(ch(i + 3)) {
|
||||
push(&mut out, Tok::Trim(TrimMode::All), i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
push(&mut out, Tok::Trim(TrimMode::Leading), i + 2);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
c if c.is_ascii_digit() || (c == locale.decimal_separator() && ch(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
|
||||
// Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers
|
||||
// followed by ':' and digits are row refs: lex as Word.
|
||||
@@ -185,12 +213,22 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
while ch(j).is_some_and(|d| d.is_ascii_digit()) {
|
||||
j += 1;
|
||||
}
|
||||
let is_row_ref = ch(j) == Some(':') && {
|
||||
let mut k = j + 1;
|
||||
if ch(k) == Some('$') {
|
||||
// Row ranges `1:3`, and the trim forms `1.:3`, `1:.3`, `1.:.3`.
|
||||
let is_row_ref = {
|
||||
let mut k = j;
|
||||
if ch(k) == Some('.') && ch(k + 1) == Some(':') {
|
||||
k += 1;
|
||||
}
|
||||
ch(k).is_some_and(|d| d.is_ascii_digit())
|
||||
ch(k) == Some(':') && {
|
||||
k += 1;
|
||||
if ch(k) == Some('.') {
|
||||
k += 1;
|
||||
}
|
||||
if ch(k) == Some('$') {
|
||||
k += 1;
|
||||
}
|
||||
ch(k).is_some_and(|d| d.is_ascii_digit())
|
||||
}
|
||||
};
|
||||
// A word like "1A" can't happen in valid formulas; numbers then.
|
||||
if is_row_ref {
|
||||
@@ -252,8 +290,13 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
i += 1;
|
||||
}
|
||||
':' => {
|
||||
push(&mut out, Tok::Colon, i + 1);
|
||||
i += 1;
|
||||
if ch(i + 1) == Some('.') && starts_range_end(ch(i + 2)) {
|
||||
push(&mut out, Tok::Trim(TrimMode::Trailing), i + 2);
|
||||
i += 2;
|
||||
} else {
|
||||
push(&mut out, Tok::Colon, i + 1);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
'+' | '-' | '*' | '/' | '^' | '&' | '%' | '@' | '=' => {
|
||||
let op = match c {
|
||||
@@ -315,6 +358,11 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
} else if w.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') {
|
||||
push(&mut out, Tok::Bool(false), j);
|
||||
i = j;
|
||||
} else if w.len() > 1 && w.ends_with('.') && ch(j) == Some(':') && is_range_end(w.get(..w.len() - 1).unwrap_or("")) {
|
||||
// `A1.:E10`: the dot belongs to the trim operator, lexed next.
|
||||
let w = w.get(..w.len() - 1).unwrap_or("").to_string();
|
||||
push(&mut out, Tok::Word(w), j - 1);
|
||||
i = j - 1;
|
||||
} else {
|
||||
push(&mut out, Tok::Word(w), j);
|
||||
i = j;
|
||||
|
||||
@@ -10,7 +10,7 @@ pub mod lexer;
|
||||
pub mod parser;
|
||||
pub mod printer;
|
||||
|
||||
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
pub use input::{FormulaLocale, parse_input, print_input};
|
||||
pub use parser::{ParseError, normalize_function_name, parse};
|
||||
pub use printer::{print, print_r1c1, quote_sheet};
|
||||
|
||||
@@ -174,6 +174,17 @@ impl Parser {
|
||||
lhs = Expr::Invoke(Box::new(lhs), args);
|
||||
continue;
|
||||
}
|
||||
if let Tok::Trim(mode) = *self.peek() {
|
||||
// A trim reference is the range operator with a modifier: same binding power.
|
||||
let bp = BinOp::Range.precedence();
|
||||
if bp < min_bp {
|
||||
break;
|
||||
}
|
||||
self.next();
|
||||
let rhs = self.expr(bp + 1, allow_union)?;
|
||||
lhs = Expr::Unary(UnOp::Trim(mode), Box::new(combine(BinOp::Range, lhs, rhs)));
|
||||
continue;
|
||||
}
|
||||
let Some(op) = self.binop(allow_union) else { break };
|
||||
let bp = op.precedence();
|
||||
if bp < min_bp {
|
||||
@@ -243,6 +254,19 @@ impl Parser {
|
||||
{
|
||||
return Ok(Expr::Unary(UnOp::At, Box::new(x)));
|
||||
}
|
||||
// Files may spell a trim reference as a function call (`_xlfn._TRO_ALL(A1:E10)`).
|
||||
let trim = match name.as_str() {
|
||||
"_TRO_LEADING" => Some(TrimMode::Leading),
|
||||
"_TRO_TRAILING" => Some(TrimMode::Trailing),
|
||||
"_TRO_ALL" => Some(TrimMode::All),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mode) = trim
|
||||
&& args.len() == 1
|
||||
&& let Some(x) = args.pop()
|
||||
{
|
||||
return Ok(Expr::Unary(UnOp::Trim(mode), Box::new(x)));
|
||||
}
|
||||
Ok(Expr::Call(name, args))
|
||||
}
|
||||
Tok::Sheet(a, b) => {
|
||||
@@ -260,27 +284,42 @@ impl Parser {
|
||||
if let Tok::Struct(_) = self.peek()
|
||||
&& word_is_cell(&w).is_none()
|
||||
{
|
||||
let pos = self.pos();
|
||||
let Tok::Struct(body) = self.next() else { return Err(self.err("internal")) };
|
||||
return Ok(Expr::Struct(parse_struct(&w, &body)));
|
||||
return parse_struct(&w, &body).map(Expr::Struct).map_err(|msg| ParseError { msg, pos });
|
||||
}
|
||||
self.reference_from_word(SheetSel::Current, &w)
|
||||
}
|
||||
Tok::Struct(body) => Ok(Expr::Struct(parse_struct("", &body))),
|
||||
Tok::Struct(body) => parse_struct("", &body).map(Expr::Struct).map_err(|msg| self.err(msg)),
|
||||
t => Err(self.err(format!("unexpected {}", describe(&t)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name.
|
||||
/// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name. A
|
||||
/// trim operator (`A1:.E10`, `A:.A`) in place of the colon wraps the range in
|
||||
/// [`UnOp::Trim`].
|
||||
fn reference_from_word(&mut self, sheet: SheetSel, w: &str) -> Result<Expr, ParseError> {
|
||||
let trim = match self.peek() {
|
||||
Tok::Colon => Some(None),
|
||||
Tok::Trim(mode) => Some(Some(*mode)),
|
||||
_ => None,
|
||||
};
|
||||
let wrap = |kind: RefKind, sheet: SheetSel| {
|
||||
let r = Expr::Ref(Reference { sheet, kind });
|
||||
match trim {
|
||||
Some(Some(mode)) => Expr::Unary(UnOp::Trim(mode), Box::new(r)),
|
||||
_ => r,
|
||||
}
|
||||
};
|
||||
if let Some(a) = word_is_cell(w) {
|
||||
// Cell, possibly `A1:B2` (merged here so the sheet applies to both ends).
|
||||
if *self.peek() == Tok::Colon
|
||||
if trim.is_some()
|
||||
&& let Tok::Word(w2) = self.peek_at(1)
|
||||
&& let Some(b) = word_is_cell(w2)
|
||||
{
|
||||
self.next();
|
||||
self.next();
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) }));
|
||||
return Ok(wrap(RefKind::Range(a, b), sheet));
|
||||
}
|
||||
let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) });
|
||||
if *self.peek() == Tok::Op("#") {
|
||||
@@ -294,18 +333,18 @@ impl Parser {
|
||||
Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")),
|
||||
_ => None,
|
||||
};
|
||||
if *self.peek() == Tok::Colon
|
||||
if trim.is_some()
|
||||
&& let Some(w2) = second
|
||||
{
|
||||
if let (Some((c0, a0)), Some((c1, a1))) = (word_is_cols(w), word_is_cols(&w2)) {
|
||||
self.next();
|
||||
self.next();
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cols(c0, a0, c1, a1) }));
|
||||
return Ok(wrap(RefKind::Cols(c0, a0, c1, a1), sheet));
|
||||
}
|
||||
if let (Some((r0, a0)), Some((r1, a1))) = (word_is_row(w), word_is_row(&w2)) {
|
||||
self.next();
|
||||
self.next();
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Rows(r0, a0, r1, a1) }));
|
||||
return Ok(wrap(RefKind::Rows(r0, a0, r1, a1), sheet));
|
||||
}
|
||||
}
|
||||
if sheet != SheetSel::Current {
|
||||
@@ -372,8 +411,10 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr {
|
||||
Expr::Binary(op, Box::new(lhs), Box::new(rhs))
|
||||
}
|
||||
|
||||
/// Parses the inside of `Table[...]`.
|
||||
pub fn parse_struct(table: &str, body: &str) -> StructRef {
|
||||
/// Parses the inside of `Table[...]`. Text between the bracketed items may only be the `,` and
|
||||
/// `:` separators, except one unbracketed column after `[#This Row],` (`[[#This Row],Units]`,
|
||||
/// how some writers spell `[@Units]`); anything else is an error rather than dropped.
|
||||
pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
|
||||
let mut specifiers = Vec::new();
|
||||
let mut cols: Vec<String> = Vec::new();
|
||||
let body = body.trim();
|
||||
@@ -401,24 +442,41 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef {
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let unexpected = |gap: &str| format!("unexpected `{gap}` in structured reference");
|
||||
// A column name may hold `[` and `]` only when escaped with `'`.
|
||||
let column = |s: &str| -> Result<String, String> {
|
||||
let mut esc = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
_ if esc => esc = false,
|
||||
'\'' => esc = true,
|
||||
'[' | ']' => return Err(unexpected(s)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(unescape(s))
|
||||
};
|
||||
if !body.starts_with('[') {
|
||||
// Simple: [Col], [@Col], [#Headers], [@]
|
||||
if let Some(rest) = body.strip_prefix('@') {
|
||||
specifiers.push(StructItem::ThisRow);
|
||||
let rest = rest.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
let rest = rest.trim();
|
||||
let rest = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')).unwrap_or(rest);
|
||||
if !rest.is_empty() {
|
||||
cols.push(unescape(rest));
|
||||
cols.push(column(rest)?);
|
||||
}
|
||||
} else if let Some(i) = item(body) {
|
||||
specifiers.push(i);
|
||||
} else if !body.is_empty() {
|
||||
cols.push(unescape(body));
|
||||
cols.push(column(body)?);
|
||||
}
|
||||
} else {
|
||||
// [[#Headers],[Col1]:[Col3]] or [@[Col 1]]
|
||||
// [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1]
|
||||
let mut parts = Vec::new();
|
||||
let mut depth = 0;
|
||||
let mut cur = String::new();
|
||||
// Text between bracketed items.
|
||||
let mut gap = String::new();
|
||||
let mut esc = false;
|
||||
for c in body.chars() {
|
||||
if esc {
|
||||
@@ -427,17 +485,26 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\'' => {
|
||||
'\'' if depth > 0 => {
|
||||
cur.push(c);
|
||||
esc = true;
|
||||
}
|
||||
'[' => {
|
||||
if depth == 0 {
|
||||
if !matches!(gap.trim(), "" | "," | ":") {
|
||||
return Err(unexpected(gap.trim()));
|
||||
}
|
||||
gap.clear();
|
||||
}
|
||||
depth += 1;
|
||||
if depth > 1 {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
']' => {
|
||||
if depth == 0 {
|
||||
return Err(unexpected("]"));
|
||||
}
|
||||
depth -= 1;
|
||||
if depth > 0 {
|
||||
cur.push(c);
|
||||
@@ -445,33 +512,35 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef {
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
',' if depth == 0 => {}
|
||||
':' if depth == 0 => parts.push(":".into()),
|
||||
_ if depth > 0 => cur.push(c),
|
||||
_ => {}
|
||||
_ => gap.push(c),
|
||||
}
|
||||
}
|
||||
let mut range_next = false;
|
||||
if depth > 0 {
|
||||
return Err("unclosed `[` in structured reference".to_string());
|
||||
}
|
||||
for p in parts {
|
||||
if p == ":" {
|
||||
range_next = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = item(&p) {
|
||||
specifiers.push(i);
|
||||
} else if range_next {
|
||||
cols.push(unescape(&p));
|
||||
range_next = false;
|
||||
} else {
|
||||
cols.push(unescape(&p));
|
||||
cols.push(column(&p)?);
|
||||
}
|
||||
}
|
||||
if body.starts_with("@") {
|
||||
specifiers.push(StructItem::ThisRow);
|
||||
let tail = gap.trim();
|
||||
if !tail.is_empty() {
|
||||
let bare = tail.strip_prefix(',').map(str::trim).unwrap_or("");
|
||||
let simple = !bare.is_empty() && !bare.chars().any(|c| matches!(c, '[' | ']' | '#' | '\'' | ',' | ':'));
|
||||
if !(simple && specifiers == [StructItem::ThisRow] && cols.is_empty()) {
|
||||
return Err(unexpected(tail));
|
||||
}
|
||||
cols.push(bare.to_string());
|
||||
}
|
||||
}
|
||||
if cols.len() > 2 {
|
||||
return Err("a structured reference names at most two columns".to_string());
|
||||
}
|
||||
let mut it = cols.into_iter();
|
||||
let col_start = it.next();
|
||||
let col_end = it.next();
|
||||
StructRef { table: table.to_string(), specifiers, col_start, col_end }
|
||||
Ok(StructRef { table: table.to_string(), specifiers, col_start, col_end })
|
||||
}
|
||||
|
||||
@@ -39,12 +39,17 @@ fn anchor_a1(a: &Anchor) -> String {
|
||||
}
|
||||
|
||||
pub fn reference_a1(r: &Reference) -> String {
|
||||
range_a1(r, ":")
|
||||
}
|
||||
|
||||
/// A1 text with `sep` between the two ends of a range (`:` or a trim operator).
|
||||
fn range_a1(r: &Reference, sep: &str) -> String {
|
||||
let body = match &r.kind {
|
||||
RefKind::Cell(a) => anchor_a1(a),
|
||||
RefKind::Range(a, b) => format!("{}:{}", anchor_a1(a), anchor_a1(b)),
|
||||
RefKind::Rows(r0, a0, r1, a1) => format!("{}{}:{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1),
|
||||
RefKind::Range(a, b) => format!("{}{sep}{}", anchor_a1(a), anchor_a1(b)),
|
||||
RefKind::Rows(r0, a0, r1, a1) => format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1),
|
||||
RefKind::Cols(c0, a0, c1, a1) => {
|
||||
format!("{}{}:{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1))
|
||||
format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1))
|
||||
}
|
||||
};
|
||||
format!("{}{}", sheet_prefix(&r.sheet), body)
|
||||
@@ -64,16 +69,22 @@ fn anchor_r1c1(a: &Anchor, at: CellRef) -> String {
|
||||
}
|
||||
|
||||
pub fn reference_r1c1(r: &Reference, at: CellRef) -> String {
|
||||
range_r1c1(r, at, ":")
|
||||
}
|
||||
|
||||
/// R1C1 text with `sep` between the two ends of a range; a trim operator never collapses
|
||||
/// `R1:R1` to `R1`.
|
||||
fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String {
|
||||
let body = match &r.kind {
|
||||
RefKind::Cell(a) => anchor_r1c1(a, at),
|
||||
RefKind::Range(a, b) => format!("{}:{}", anchor_r1c1(a, at), anchor_r1c1(b, at)),
|
||||
RefKind::Range(a, b) => format!("{}{sep}{}", anchor_r1c1(a, at), anchor_r1c1(b, at)),
|
||||
RefKind::Rows(r0, a0, r1, a1) => {
|
||||
let (x, y) = (r1c1_part('R', *r0, *a0, at.row), r1c1_part('R', *r1, *a1, at.row));
|
||||
if x == y { x } else { format!("{x}:{y}") }
|
||||
if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") }
|
||||
}
|
||||
RefKind::Cols(c0, a0, c1, a1) => {
|
||||
let (x, y) = (r1c1_part('C', *c0, *a0, at.col), r1c1_part('C', *c1, *a1, at.col));
|
||||
if x == y { x } else { format!("{x}:{y}") }
|
||||
if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") }
|
||||
}
|
||||
};
|
||||
format!("{}{}", sheet_prefix(&r.sheet), body)
|
||||
@@ -193,6 +204,25 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
s.push('#');
|
||||
}
|
||||
}
|
||||
UnOp::Trim(mode) => match &**x {
|
||||
Expr::Ref(r) if !matches!(r.kind, RefKind::Cell(_)) => match r1c1 {
|
||||
Some(at) => s.push_str(&range_r1c1(r, at, mode.symbol())),
|
||||
None => s.push_str(&range_a1(r, mode.symbol())),
|
||||
},
|
||||
Expr::Binary(BinOp::Range, a, b) => {
|
||||
write_expr(s, a, r1c1);
|
||||
s.push_str(mode.symbol());
|
||||
write_expr(s, b, r1c1);
|
||||
}
|
||||
// A reference that became #REF! stays `#REF!`.
|
||||
Expr::Error(_) => write_expr(s, x, r1c1),
|
||||
// No operator form (e.g. a range shrunk to one cell): the equivalent call.
|
||||
other => {
|
||||
s.push_str("TRIMRANGE(");
|
||||
write_expr(s, other, r1c1);
|
||||
let _ = write!(s, ",{0},{0})", mode.code());
|
||||
}
|
||||
},
|
||||
},
|
||||
Expr::Binary(op, a, b) => {
|
||||
write_expr(s, a, r1c1);
|
||||
|
||||
@@ -220,3 +220,84 @@ fn spans() {
|
||||
let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).unwrap();
|
||||
assert_eq!((a1.start, a1.end), (4, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbracketed_this_row_column() {
|
||||
// Writers such as xlsxwriter spell `[@Units]` as `[[#This Row],Units]`.
|
||||
let p = |f: &str| parse(f).unwrap_or_else(|e| panic!("{f}: {e}"));
|
||||
let Expr::Binary(BinOp::Mul, a, _) = p("[[#This Row],Units]*[[#This Row],Price]") else { panic!() };
|
||||
let Expr::Struct(s) = *a else { panic!() };
|
||||
assert_eq!((s.specifiers.as_slice(), s.col_start.as_deref()), ([StructItem::ThisRow].as_slice(), Some("Units")));
|
||||
assert_eq!(rt("[[#This Row],Units]*[[#This Row],Price]"), "[@Units]*[@Price]");
|
||||
assert_eq!(rt("Sales[[#This Row],Units]"), "Sales[@Units]");
|
||||
assert_eq!(rt("Sales[[#This Row], Units ]"), "Sales[@Units]");
|
||||
assert_eq!(rt("[[#This Row],Units]+[@[Unit Price]]+Sales[[#This Row],[Qty]]"), "[@Units]+[@[Unit Price]]+Sales[@Qty]");
|
||||
// Unexpected text between the brackets is an error, never silently dropped.
|
||||
for bad in [
|
||||
"[[#This Row]x]",
|
||||
"[[#This Row],[A]]x",
|
||||
"[[#Data],Col]",
|
||||
"[[#This Row],A:B]",
|
||||
"[x[#This Row]]",
|
||||
"[[#This Row],]x",
|
||||
"Sales[[#All],[A]]junk",
|
||||
"Sales[[A],[B],[C]]",
|
||||
"Sales[@[A]x]",
|
||||
] {
|
||||
assert!(parse(bad).is_err(), "{bad} should fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_references() {
|
||||
for f in [
|
||||
"SUM(A1:.E10)",
|
||||
"SUM(A1.:E10)",
|
||||
"SUM(A1.:.E10)",
|
||||
"SUM(A:.A)",
|
||||
"SUM($A.:.$C)",
|
||||
"ROWS(1:.5)",
|
||||
"ROWS($1.:$5)",
|
||||
"ROWS(1.:.5)",
|
||||
"Sheet2!$A$1:.B9",
|
||||
"Q1.2026!A1.:.B2",
|
||||
"'Q1 2026'!A1:.B2",
|
||||
"SUM(A1:.A9,B1.:B9)",
|
||||
"A1:.A9*2",
|
||||
] {
|
||||
assert_eq!(rt(f), f, "round trip of {f}");
|
||||
}
|
||||
let Expr::Call(_, args) = parse("SUM(A1:.E10)").unwrap() else { panic!() };
|
||||
assert!(matches!(args.as_slice(), [Expr::Unary(UnOp::Trim(TrimMode::Trailing), r)] if matches!(**r, Expr::Ref(_))));
|
||||
// Decimals, row ranges and dotted names keep their meaning.
|
||||
assert_eq!(parse("1.5+.5").unwrap(), Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5))));
|
||||
for f in ["SUM(1:5)", "Q1.2026!A1:B2", "my.name+1", "SUM(A1:B2)*1.5", "1.:5"] {
|
||||
assert_eq!(rt(f), f);
|
||||
}
|
||||
assert_eq!(rt("'Q1.2026'!A1:B2"), "Q1.2026!A1:B2");
|
||||
assert!(matches!(parse("Q1.2026!A1:B2").unwrap(), Expr::Ref(_)));
|
||||
// File call forms.
|
||||
assert_eq!(rt("SUM(_xlfn._TRO_TRAILING(A1:E10))"), "SUM(A1:.E10)");
|
||||
assert_eq!(rt("_xlfn._TRO_LEADING(A:A)"), "A.:A");
|
||||
assert_eq!(rt("_xlfn._TRO_ALL(Sheet1!A1:B2)"), "Sheet1!A1.:.B2");
|
||||
// Not a range: printed as the equivalent call.
|
||||
assert_eq!(rt("_xlfn._TRO_ALL(B2)"), "TRIMRANGE(B2,3,3)");
|
||||
for bad in ["A1.:", ".:A1", "A1:.:B2", "A1.:.:B2"] {
|
||||
assert!(parse(bad).is_err(), "{bad} should fail");
|
||||
}
|
||||
// R1C1 keeps the operator.
|
||||
assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)").unwrap(), gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_references_adjust() {
|
||||
let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 };
|
||||
let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)").unwrap();
|
||||
assert_eq!(print(&adjust(e, "S", "S", &ins)), "SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)");
|
||||
let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 };
|
||||
let e = parse("SUM(A1.:C1)+SUM(A:.A)").unwrap();
|
||||
assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1.:B1)+SUM(#REF!)");
|
||||
assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)").unwrap(), 1, 1)), "SUM(B2.:.C3)");
|
||||
let refs: Vec<String> = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect();
|
||||
assert_eq!(refs, ["Sheet2!A1:B5"]);
|
||||
}
|
||||
|
||||
@@ -496,7 +496,16 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
f!("ISOWEEKNUM", 1, 1, DateTime, S, "ISOWEEKNUM(date)", "Returns the ISO 8601 week number of a date.", isoweeknum),
|
||||
f!("EDATE", 2, 2, DateTime, S, "EDATE(start_date, months)", "Returns the date a number of months before or after a date.", edate),
|
||||
f!("EOMONTH", 2, 2, DateTime, S, "EOMONTH(start_date, months)", "Returns the last day of the month a number of months away.", eomonth),
|
||||
f!("DATEDIF", 3, 3, DateTime, S, "DATEDIF(start_date, end_date, unit)", "Counts whole years, months or days between two dates.", datedif),
|
||||
f!(
|
||||
"DATEDIF",
|
||||
3,
|
||||
3,
|
||||
DateTime,
|
||||
S,
|
||||
"DATEDIF(start_date, end_date, unit)",
|
||||
"Counts whole years, months or days between two dates. unit: Y, M, D, MD, YM or YD.",
|
||||
datedif
|
||||
),
|
||||
f!("DATEVALUE", 1, 1, DateTime, S, "DATEVALUE(date_text)", "Converts a date written as text into a date serial number.", datevalue),
|
||||
f!("TIMEVALUE", 1, 1, DateTime, S, "TIMEVALUE(time_text)", "Converts a time written as text into a fraction of a day.", timevalue),
|
||||
f!("DAYS", 2, 2, DateTime, S, "DAYS(end_date, start_date)", "Returns the number of days between two dates.", days),
|
||||
|
||||
@@ -160,6 +160,11 @@ impl FnSpec {
|
||||
None => self.scalar_args.last().copied().unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a call with `n` arguments is valid (Excel allows at most 255).
|
||||
pub fn accepts(&self, n: usize) -> bool {
|
||||
(self.min_args..=self.max_args).contains(&n) && n <= 255
|
||||
}
|
||||
}
|
||||
|
||||
/// Variadic marker for `max_args`.
|
||||
@@ -215,7 +220,7 @@ pub fn all() -> &'static [FnSpec] {
|
||||
|
||||
/// Calls a function with array lifting applied. Wrong arity gives `#VALUE!`.
|
||||
pub fn call(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
|
||||
if args.len() < spec.min_args || args.len() > spec.max_args || args.len() > 255 {
|
||||
if !spec.accepts(args.len()) {
|
||||
return Value::Error(CellError::Value);
|
||||
}
|
||||
lift::call_lifted(spec, args, ctx)
|
||||
|
||||
+141
-32
@@ -33,43 +33,77 @@ fn find_exact(x: &Value, hay: &[Value], wild: bool, reverse: bool) -> Option<usi
|
||||
if reverse { hay.iter().rposition(|v| lookup_equal(x, v, wild)) } else { hay.iter().position(|v| lookup_equal(x, v, wild)) }
|
||||
}
|
||||
|
||||
/// Binary search over the entries of the needle's type, assuming they are sorted ascending
|
||||
/// (`desc = false`) or descending. Returns the position of the last entry that is `<= x`
|
||||
/// (ascending) or `>= x` (descending), like Excel's approximate match.
|
||||
/// Approximate match of VLOOKUP, HLOOKUP, LOOKUP and MATCH: the position of the largest entry
|
||||
/// `<= x` in data sorted ascending (`desc = false`), or of the smallest entry `>= x` in data
|
||||
/// sorted descending. Like Excel this is a bisection over the whole vector, so on unsorted data
|
||||
/// it answers what Excel answers rather than a linear "best" entry:
|
||||
/// - the probe is the middle entry of the remaining window; a probe whose type differs from the
|
||||
/// needle (text vs number vs logical, blanks, errors) is replaced by the nearest entry of the
|
||||
/// needle's type below it in the window; when there is none the window moves up past it, so
|
||||
/// entries of other types are skipped (`LOOKUP(9.9E+307, range)` finds the last number);
|
||||
/// - a probe equal to the needle ends the search at once (the run of equal entries that follows
|
||||
/// it is taken, so sorted duplicates give the last one).
|
||||
///
|
||||
/// Observed in Excel: `VLOOKUP(534, {"INTEGRAL";0;1;2;534;9999999999;-9999999999}, …)` finds 534,
|
||||
/// `HLOOKUP("STRING", {"INTEGRAL","DOUBLE","BLANK","STRING","REF","AREA (rows)",<blank>}, …)`
|
||||
/// finds "STRING" and `LOOKUP("DOUBLE", {"DOUBLE","BLANK","STRING","REF","AREA (rows)"}, …)`
|
||||
/// finds "DOUBLE". Work is linear in the vector length at worst.
|
||||
fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
let idx: Vec<usize> = hay.iter().enumerate().filter(|(_, v)| same_type(x, v)).map(|(i, _)| i).collect();
|
||||
let (mut lo, mut hi) = (0usize, idx.len());
|
||||
// Find the first filtered position where the "passes" predicate fails.
|
||||
while lo < hi {
|
||||
let mut hi = hay.len().checked_sub(1)?;
|
||||
let mut lo = 0usize;
|
||||
let mut best = None;
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let v = idx.get(mid).and_then(|&i| hay.get(i))?;
|
||||
let o = compare(v, x);
|
||||
let ok = if desc { o != Ordering::Less } else { o != Ordering::Greater };
|
||||
if ok {
|
||||
let Some(j) = (lo..=mid).rev().find(|&j| hay.get(j).is_some_and(|v| same_type(x, v))) else {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
if lo == 0 { None } else { idx.get(lo - 1).copied() }
|
||||
}
|
||||
|
||||
/// Binary search returning the first entry `>= x` (ascending) or `<= x` (descending).
|
||||
fn bsearch_first(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
let idx: Vec<usize> = hay.iter().enumerate().filter(|(_, v)| same_type(x, v)).map(|(i, _)| i).collect();
|
||||
let (mut lo, mut hi) = (0usize, idx.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let v = idx.get(mid).and_then(|&i| hay.get(i))?;
|
||||
continue;
|
||||
};
|
||||
let v = hay.get(j)?;
|
||||
let o = compare(v, x);
|
||||
if o == Ordering::Equal {
|
||||
let mut k = j;
|
||||
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
|
||||
k += 1;
|
||||
}
|
||||
return Some(k);
|
||||
}
|
||||
let before = if desc { o == Ordering::Greater } else { o == Ordering::Less };
|
||||
if before {
|
||||
best = Some(j);
|
||||
lo = mid + 1;
|
||||
} else if j == 0 {
|
||||
break;
|
||||
} else {
|
||||
hi = mid;
|
||||
hi = j - 1;
|
||||
}
|
||||
}
|
||||
idx.get(lo).copied()
|
||||
best
|
||||
}
|
||||
|
||||
/// Order of an entry relative to the needle for XLOOKUP / XMATCH binary search (search mode ±2):
|
||||
/// Excel's sort order over all types (numbers < text < logicals < errors), reversed for
|
||||
/// descending data, with blanks always last like a sort leaves them. Unlike the legacy
|
||||
/// approximate match no entry is skipped (observed: `XLOOKUP(46523, {9700;"xyz";"xyz";160726;
|
||||
/// 204100;510300}, …, 0, 1, 2)` returns the row of the first "xyz").
|
||||
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
|
||||
match v {
|
||||
Value::Empty => Ordering::Greater,
|
||||
_ if desc => compare(v, x).reverse(),
|
||||
_ => compare(v, x),
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary search for the first entry not ordered before the needle (`>= x` ascending, `<= x`
|
||||
/// descending).
|
||||
fn bsearch_first(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
let p = hay.partition_point(|v| binary_order(v, x, desc) == Ordering::Less);
|
||||
(p < hay.len()).then_some(p)
|
||||
}
|
||||
|
||||
/// Binary search for the last entry not ordered after the needle (`<= x` ascending, `>= x`
|
||||
/// descending).
|
||||
fn bsearch_upto(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
hay.partition_point(|v| binary_order(v, x, desc) != Ordering::Greater).checked_sub(1)
|
||||
}
|
||||
|
||||
/// A 1-D view of an array (row or column), `None` when it is 2-D.
|
||||
@@ -199,12 +233,13 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
|
||||
if desc {
|
||||
bsearch_first(x, hay, true)
|
||||
} else {
|
||||
bsearch_last(x, hay, false)
|
||||
bsearch_upto(x, hay, false)
|
||||
}
|
||||
}
|
||||
// Next larger: first >= x (ascending) / last >= x (descending).
|
||||
_ => {
|
||||
if desc {
|
||||
bsearch_last(x, hay, true)
|
||||
bsearch_upto(x, hay, true)
|
||||
} else {
|
||||
bsearch_first(x, hay, false)
|
||||
}
|
||||
@@ -248,7 +283,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
|
||||
fn bsearch_exact(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
|
||||
let p = bsearch_first(x, hay, desc)?;
|
||||
let v = hay.get(p)?;
|
||||
if compare(v, x) == Ordering::Equal { Some(p) } else { None }
|
||||
if binary_order(v, x, desc) == Ordering::Equal { Some(p) } else { None }
|
||||
}
|
||||
|
||||
fn xlookup(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -378,9 +413,16 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sheet text for ADDRESS, quoted when it needs to be. A `[Book]Sheet` prefix is judged by its
|
||||
/// parts (Microsoft's ADDRESS examples: `"[Book1]Sheet1"` stays bare, `"EXCEL SHEET"` becomes
|
||||
/// `'EXCEL SHEET'`).
|
||||
fn quote_sheet(s: &str) -> String {
|
||||
let plain =
|
||||
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') && !s.chars().next().is_some_and(|c| c.is_ascii_digit());
|
||||
let word = |p: &str| p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.');
|
||||
let bare_sheet = |p: &str| !p.is_empty() && word(p) && !p.chars().next().is_some_and(|c| c.is_ascii_digit());
|
||||
let plain = match s.strip_prefix('[').and_then(|r| r.split_once(']')) {
|
||||
Some((book, sheet)) => !book.is_empty() && word(book) && bare_sheet(sheet),
|
||||
None => bare_sheet(s),
|
||||
};
|
||||
if plain { s.to_string() } else { format!("'{}'", s.replace('\'', "''")) }
|
||||
}
|
||||
|
||||
@@ -574,6 +616,45 @@ mod tests {
|
||||
close(ev("LOOKUP", vec![t("zz"), av(mixed), av(row(&[1.0, 2.0, 3.0, 4.0]))]), 4.0);
|
||||
}
|
||||
|
||||
/// Approximate matches over unsorted, mixed-type data as observed in Excel
|
||||
/// (FormulaEvalTestData rows "Vlookup", "Hlookup", "Lookup"): Excel bisects the whole
|
||||
/// vector and stops at an equal entry, so answers differ from a "best entry" scan.
|
||||
#[test]
|
||||
fn approximate_match_follows_bisection() {
|
||||
// B6:H12 of the test data: header row, then one row per data kind.
|
||||
let data = arr(vec![
|
||||
vec![tv("INTEGRAL"), tv("DOUBLE"), tv("BLANK"), tv("STRING"), tv("REF"), tv("AREA (rows)"), Value::Empty],
|
||||
vec![nv(0.0), nv(0.0), Value::Empty, tv(""), nv(0.0), nv(1.0), nv(2.0)],
|
||||
vec![nv(1.0), nv(0.00001), Value::Empty, tv("nospaces"), nv(1.0), nv(1.001), nv(2.999)],
|
||||
vec![nv(2.0), nv(1.1), Value::Empty, tv("sp ace s"), nv(9999999999.0), nv(2.3456), nv(3.4567)],
|
||||
vec![nv(534.0), nv(1.00001), Value::Empty, tv("endsWithSpace "), nv(0.00001), tv("alphanum"), nv(3.0)],
|
||||
vec![nv(9999999999.0), nv(2.99999), Value::Empty, tv(" beginsWithSpace"), nv(0.0), tv("blanksIncl"), Value::Empty],
|
||||
vec![nv(-9999999999.0), nv(999999.99999), Value::Empty, tv("non-numeric"), tv("sp ace s"), tv("E8"), nv(545.0)],
|
||||
]);
|
||||
close(ev("VLOOKUP", vec![n(534.0), rf(data.clone()), n(2.0)]), 1.00001);
|
||||
is_text(ev("VLOOKUP", vec![n(534.0), rf(data.clone()), n(4.0)]), "endsWithSpace ");
|
||||
is_text(ev("VLOOKUP", vec![n(534.0), rf(data.clone()), n(4.0), b(true)]), "endsWithSpace ");
|
||||
is_text(ev("HLOOKUP", vec![t("STRING"), rf(data.clone()), n(6.0), b(true)]), " beginsWithSpace");
|
||||
let keys = arr(vec![vec![tv("DOUBLE"), tv("BLANK"), tv("STRING"), tv("REF"), tv("AREA (rows)")]]);
|
||||
let results = arr(vec![vec![nv(1.1), Value::Empty, tv("sp ace s"), nv(9999999999.0), nv(2.3456)]]);
|
||||
close(ev("LOOKUP", vec![t("DOUBLE"), rf(keys), rf(results)]), 1.1);
|
||||
let b6_b15 = col(&[0.0, 1.0, 2.0, 534.0, 9999999999.0, -9999999999.0, -534.0, -2.0, -1.0]);
|
||||
let mut keys = vec![tv("INTEGRAL")];
|
||||
keys.extend(rows_of(&b6_b15).into_iter().flatten());
|
||||
let words = ["STRING", "", "nospaces", "sp ace s", "endsWithSpace ", " beginsWithSpace", "non-numeric", "0", "0.000001", "1"];
|
||||
let e_col = arr(words.iter().map(|w| vec![tv(w)]).collect());
|
||||
is_text(ev("LOOKUP", vec![n(534.0), rf(arr(keys.into_iter().map(|k| vec![k]).collect())), rf(e_col)]), "endsWithSpace ");
|
||||
// Sorted duplicates give the last one; descending data with MATCH -1 too.
|
||||
close(ev("MATCH", vec![n(2.0), av(row(&[1.0, 2.0, 2.0, 2.0, 3.0])), n(1.0)]), 4.0);
|
||||
close(ev("MATCH", vec![n(20.0), av(row(&[40.0, 20.0, 20.0, 10.0])), n(-1.0)]), 3.0);
|
||||
// Only text and blanks: no number to find.
|
||||
is_err(ev("MATCH", vec![n(5.0), av(arr(vec![vec![tv("a"), Value::Empty, tv("b")]]))]), CellError::NA);
|
||||
// A long run of trailing blanks (whole-column lookups) still finds the last number.
|
||||
let mut tall = vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(3.0)]];
|
||||
tall.extend((0..10_000).map(|_| vec![Value::Empty]));
|
||||
close(ev("MATCH", vec![n(9.0), rf(arr(tall))]), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_fn() {
|
||||
let v = col(&[10.0, 20.0, 30.0, 40.0]);
|
||||
@@ -632,6 +713,23 @@ mod tests {
|
||||
is_err(ev("XLOOKUP", vec![n(2.0), rf(table()), rf(vals)]), CellError::Value);
|
||||
}
|
||||
|
||||
/// XLOOKUP binary search (search mode ±2). Observed in Excel (xlookup.xlsx, Sheet2): next
|
||||
/// larger over ascending data with text mixed in (0.22) and over descending data (0.24); the
|
||||
/// bisection compares across types in sort order (numbers < text) instead of skipping text.
|
||||
/// The other modes follow from the same order.
|
||||
#[test]
|
||||
fn xlookup_binary_search_observed() {
|
||||
let rates = col(&[0.1, 0.22, 0.24, 0.32, 0.35, 0.37]);
|
||||
let incomes = arr(vec![vec![nv(9700.0)], vec![tv("xyz")], vec![tv("xyz")], vec![nv(160726.0)], vec![nv(204100.0)], vec![nv(510300.0)]]);
|
||||
close(ev("XLOOKUP", vec![n(46523.0), rf(incomes.clone()), rf(rates.clone()), n(0.0), n(1.0), n(2.0)]), 0.22);
|
||||
close(ev("XLOOKUP", vec![n(46523.0), rf(incomes), rf(rates), n(0.0), n(-1.0), n(2.0)]), 0.1);
|
||||
let desc_incomes = col(&[510300.0, 204100.0, 160726.0, 84200.0, 39475.0, 9700.0]);
|
||||
let desc_rates = col(&[0.37, 0.35, 0.32, 0.24, 0.22, 0.1]);
|
||||
close(ev("XLOOKUP", vec![n(46523.0), rf(desc_incomes.clone()), rf(desc_rates.clone()), n(0.0), n(1.0), n(-2.0)]), 0.24);
|
||||
close(ev("XLOOKUP", vec![n(46523.0), rf(desc_incomes.clone()), rf(desc_rates.clone()), n(0.0), n(-1.0), n(-2.0)]), 0.22);
|
||||
close(ev("XLOOKUP", vec![n(84200.0), rf(desc_incomes), rf(desc_rates), n(0.0), n(0.0), n(-2.0)]), 0.24);
|
||||
}
|
||||
|
||||
fn table_rows3() -> Value {
|
||||
arr(vec![vec![nv(1.0), tv("one"), nv(10.0)], vec![nv(2.0), tv("two"), nv(20.0)], vec![nv(3.0), tv("three"), nv(30.0)]])
|
||||
}
|
||||
@@ -682,6 +780,17 @@ mod tests {
|
||||
is_text(ev("ADDRESS", vec![n(2.0), n(3.0), n(4.0), b(false)]), "R[2]C[3]");
|
||||
is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t("Sheet1")]), "Sheet1!$A$1");
|
||||
is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t("My Sheet")]), "'My Sheet'!$A$1");
|
||||
// Observed in Excel (FormulaEvalTestData) and Microsoft's ADDRESS examples; the last
|
||||
// case follows from the quoting rule for sheet names.
|
||||
let cases: [(f64, f64, f64, bool, &str, &str); 4] = [
|
||||
(2.0, 3.0, 3.0, true, "[Book1]Sheet1", "[Book1]Sheet1!$C2"),
|
||||
(2.0, 3.0, 1.0, false, "[Book1]Sheet1", "[Book1]Sheet1!R2C3"),
|
||||
(2.0, 3.0, 1.0, false, "EXCEL SHEET", "'EXCEL SHEET'!R2C3"),
|
||||
(1.0, 1.0, 1.0, true, "[My Book]Sheet1", "'[My Book]Sheet1'!$A$1"),
|
||||
];
|
||||
for (r, c, abs, a1, sheet, want) in cases {
|
||||
is_text(ev("ADDRESS", vec![n(r), n(c), n(abs), b(a1), t(sheet)]), want);
|
||||
}
|
||||
is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576");
|
||||
is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value);
|
||||
is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value);
|
||||
|
||||
@@ -109,20 +109,24 @@ fn product(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(v.iter().product())
|
||||
}
|
||||
|
||||
/// SUMPRODUCT: non-numeric entries of arrays and multi-cell ranges count as 0, but a scalar
|
||||
/// argument holding text (a literal or a single-cell reference) is `#VALUE!` (observed:
|
||||
/// `SUMPRODUCT(G1330:G1330,I1330:I1330)` with G1330 = the text "4" is `#VALUE!`).
|
||||
fn sumproduct(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let arrays: Vec<_> = a.iter().map(|x| as_array(&x.value)).collect();
|
||||
let Some(first) = arrays.first() else { return Err(CellError::Value) };
|
||||
let arrays: Vec<_> = a.iter().map(|x| (as_array(&x.value), !matches!(x.value, Value::Array(_)))).collect();
|
||||
let Some((first, _)) = arrays.first() else { return Err(CellError::Value) };
|
||||
let (rows, cols) = (first.rows, first.cols);
|
||||
if arrays.iter().any(|x| x.rows != rows || x.cols != cols) {
|
||||
if arrays.iter().any(|(x, _)| x.rows != rows || x.cols != cols) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let mut total = 0.0;
|
||||
for i in 0..rows.saturating_mul(cols) {
|
||||
let mut p = 1.0;
|
||||
for arr in &arrays {
|
||||
for (arr, scalar) in &arrays {
|
||||
match arr.data.get(i) {
|
||||
Some(Value::Number(n)) => p *= n,
|
||||
Some(Value::Error(e)) => return Err(*e),
|
||||
Some(Value::Text(_)) if *scalar => return Err(CellError::Value),
|
||||
_ => p = 0.0,
|
||||
}
|
||||
}
|
||||
@@ -1336,6 +1340,27 @@ mod tests {
|
||||
is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA);
|
||||
}
|
||||
|
||||
/// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and
|
||||
/// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match.
|
||||
#[test]
|
||||
fn sumproduct_scalar_arguments() {
|
||||
let cases: Vec<(Vec<crate::Arg>, Result<f64, CellError>)> = vec![
|
||||
(vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)),
|
||||
(vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)),
|
||||
(vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)),
|
||||
(vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)),
|
||||
(vec![t("4"), n(2.0)], Err(CellError::Value)),
|
||||
(vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)),
|
||||
(vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)),
|
||||
];
|
||||
for (args, want) in cases {
|
||||
match want {
|
||||
Ok(x) => close(ev("SUMPRODUCT", args), x),
|
||||
Err(err) => is_err(ev("SUMPRODUCT", args), err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding() {
|
||||
close(ev2("ROUND", 2.675, 2.0), 2.68);
|
||||
|
||||
@@ -78,6 +78,34 @@ fn mode_sngl(v: &[f64]) -> R<f64> {
|
||||
modes(v).first().copied().ok_or(CellError::NA)
|
||||
}
|
||||
|
||||
/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers
|
||||
/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!`
|
||||
/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or
|
||||
/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`).
|
||||
/// Arguments are processed left to right, so the first error wins.
|
||||
fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
|
||||
let mut out = Vec::new();
|
||||
for a in args {
|
||||
match &a.value {
|
||||
Value::Array(arr) => {
|
||||
for v in arr.iter() {
|
||||
match v {
|
||||
Value::Number(n) => out.push(*n),
|
||||
Value::Error(e) => return Err(*e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Number(n) => out.push(*n),
|
||||
Value::Error(e) => return Err(*e),
|
||||
Value::Text(_) | Value::Empty => return Err(CellError::Value),
|
||||
Value::Bool(_) if a.from_ref => {}
|
||||
v => out.push(v.to_number()?),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn product(v: &[f64]) -> f64 {
|
||||
if v.is_empty() { 0.0 } else { v.iter().product() }
|
||||
}
|
||||
@@ -277,7 +305,7 @@ fn countblank(a: &[Arg], _: C) -> R<Value> {
|
||||
}
|
||||
|
||||
fn mode_mult(a: &[Arg], _: C) -> R<Value> {
|
||||
let v = numbers(a)?;
|
||||
let v = mode_numbers(a)?;
|
||||
let m = modes(&v);
|
||||
if m.is_empty() {
|
||||
return Err(CellError::NA);
|
||||
@@ -1566,10 +1594,10 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
numbers(a)?
|
||||
)?)),
|
||||
f!("MODE", 1, VAR, Compatibility, A, "MODE(number1, [number2], ...)", "Most frequently occurring number.", |a: &[Arg], _c: C| num_val(
|
||||
mode_sngl(&numbers(a)?)?
|
||||
mode_sngl(&mode_numbers(a)?)?
|
||||
)),
|
||||
f!("MODE.SNGL", 1, VAR, Statistical, A, "MODE.SNGL(number1, [number2], ...)", "Most frequently occurring number.", |a: &[Arg], _c: C| {
|
||||
num_val(mode_sngl(&numbers(a)?)?)
|
||||
num_val(mode_sngl(&mode_numbers(a)?)?)
|
||||
}),
|
||||
f!("MODE.MULT", 1, VAR, Statistical, A, "MODE.MULT(number1, [number2], ...)", "Vertical array of all the most frequent numbers.", mode_mult),
|
||||
f!("STDEV", 1, VAR, Compatibility, A, "STDEV(number1, [number2], ...)", "Sample standard deviation.", |a: &[Arg], _c: C| num_val(
|
||||
@@ -2251,6 +2279,35 @@ mod tests {
|
||||
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]);
|
||||
}
|
||||
|
||||
/// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell
|
||||
/// ranges skip text and blanks, but direct text and single-cell references to text or blank
|
||||
/// cells are `#VALUE!`; the first error from the left wins.
|
||||
#[test]
|
||||
fn mode_scalar_arguments() {
|
||||
let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]);
|
||||
let cases: Vec<(Vec<crate::Arg>, Result<f64, CellError>)> = vec![
|
||||
(vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)),
|
||||
(vec![rf(data)], Ok(1.0)),
|
||||
(vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)),
|
||||
// MODE(C9,E16,"1",B8): E16 holds the text "1.1".
|
||||
(vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)),
|
||||
(vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)),
|
||||
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)),
|
||||
// MODE(B9,2,D7) with D7 blank.
|
||||
(vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)),
|
||||
(vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)),
|
||||
];
|
||||
for name in ["MODE", "MODE.SNGL"] {
|
||||
for (args, want) in &cases {
|
||||
match want {
|
||||
Ok(x) => close(ev(name, args.clone()), *x),
|
||||
Err(err) => is_err(ev(name, args.clone()), *err),
|
||||
}
|
||||
}
|
||||
}
|
||||
is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread() {
|
||||
let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0];
|
||||
|
||||
@@ -292,17 +292,14 @@ fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(ch as u32 as f64)
|
||||
}
|
||||
|
||||
/// VALUE: a blank cell reads as 0, but empty or blank text is not a number (observed:
|
||||
/// `VALUE("")` is `#VALUE!`).
|
||||
fn value(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
match scalar(arg(a, 0)?) {
|
||||
Value::Number(n) => num_val(n),
|
||||
Value::Empty => num_val(0.0),
|
||||
Value::Error(e) => Err(e),
|
||||
Value::Text(t) => {
|
||||
if t.trim().is_empty() {
|
||||
return num_val(0.0);
|
||||
}
|
||||
parse_number_text(&t).ok_or(CellError::Value).and_then(num_val)
|
||||
}
|
||||
Value::Text(t) => parse_number_text(&t).ok_or(CellError::Value).and_then(num_val),
|
||||
_ => Err(CellError::Value),
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1051,25 @@ mod tests {
|
||||
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
|
||||
}
|
||||
|
||||
/// VALUE cases observed in Excel (ValueFunctionOfBlank, FormulaEvalTestData); a blank cell
|
||||
/// still reads as 0.
|
||||
#[test]
|
||||
fn value_observed() {
|
||||
let cases: Vec<(crate::Arg, Result<f64, CellError>)> = vec![
|
||||
(t(""), Err(CellError::Value)),
|
||||
(t("ABC"), Err(CellError::Value)),
|
||||
(t("0.33"), Ok(0.33)),
|
||||
(t("$1,000"), Ok(1000.0)),
|
||||
(rf(Value::Empty), Ok(0.0)),
|
||||
];
|
||||
for (arg, want) in cases {
|
||||
match want {
|
||||
Ok(x) => close(ev("VALUE", vec![arg]), x),
|
||||
Err(err) => is_err(ev("VALUE", vec![arg]), err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_from_text() {
|
||||
close(ev("VALUE", vec![t("1,234.5")]), 1234.5);
|
||||
|
||||
@@ -65,6 +65,11 @@ pub struct Table {
|
||||
pub first_col: bool,
|
||||
pub last_col: bool,
|
||||
pub filter_button: bool,
|
||||
/// Criteria of the table's AutoFilter (column offset within the table → criterion). With
|
||||
/// criteria present, hidden data rows are filtered out rather than hidden by hand, which
|
||||
/// SUBTOTAL distinguishes.
|
||||
#[serde(default)]
|
||||
pub filter: Vec<(u32, FilterCriterion)>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
@@ -388,6 +393,65 @@ pub struct Series {
|
||||
/// Draw line segments as a smoothed (spline) curve.
|
||||
#[serde(default)]
|
||||
pub smooth: bool,
|
||||
/// Trendlines fitted to this series (Excel allows several per series).
|
||||
#[serde(default)]
|
||||
pub trendlines: Vec<Trendline>,
|
||||
}
|
||||
|
||||
/// Regression a trendline fits (`c:trendlineType`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TrendlineKind {
|
||||
#[default]
|
||||
Linear,
|
||||
/// `y = c·e^(b·x)`.
|
||||
Exponential,
|
||||
/// `y = c·ln(x) + b`.
|
||||
Logarithmic,
|
||||
/// Polynomial of this order (2..=6).
|
||||
Polynomial(u8),
|
||||
/// `y = c·x^b`.
|
||||
Power,
|
||||
/// Average of this many consecutive points (2..=255).
|
||||
MovingAverage(u32),
|
||||
}
|
||||
|
||||
/// DrawingML preset line dash (`a:prstDash`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LineDash {
|
||||
#[default]
|
||||
Solid,
|
||||
Dot,
|
||||
Dash,
|
||||
LgDash,
|
||||
DashDot,
|
||||
LgDashDot,
|
||||
LgDashDotDot,
|
||||
SysDash,
|
||||
SysDot,
|
||||
SysDashDot,
|
||||
SysDashDotDot,
|
||||
}
|
||||
|
||||
/// A chart trendline (Add Chart Element ▸ Trendline, Format Trendline pane).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Trendline {
|
||||
pub kind: TrendlineKind,
|
||||
/// Custom name shown in the legend; `None` = automatic ("Linear (Sales)").
|
||||
pub name: Option<String>,
|
||||
/// Forecast past the last point, in x units (categories on a category axis).
|
||||
pub forward: f64,
|
||||
/// Forecast before the first point, in x units.
|
||||
pub backward: f64,
|
||||
/// Set Intercept: fixed y at x = 0 (linear, exponential, polynomial).
|
||||
pub intercept: Option<f64>,
|
||||
pub display_equation: bool,
|
||||
pub display_r_squared: bool,
|
||||
/// Line colour; `None` = the series colour.
|
||||
pub color: Option<Color>,
|
||||
/// Line width in points; `None` = default.
|
||||
pub width: Option<f32>,
|
||||
/// Line dash; `None` = default.
|
||||
pub dash: Option<LineDash>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -534,6 +598,12 @@ pub enum FilterCriterion {
|
||||
AboveAverage(bool),
|
||||
FillColor(Color),
|
||||
FontColor(Color),
|
||||
/// Rows whose cell shows icon `id` (0-based; `None` = no icon) of conditional-format icon set
|
||||
/// `set` (`iconFilter`). Kept from files; re-applying the filter leaves those rows as they are.
|
||||
Icon {
|
||||
set: String,
|
||||
id: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -221,6 +221,20 @@ impl Sheet {
|
||||
|
||||
// ---- geometry
|
||||
|
||||
/// Whether this cell has a table or ordinary AutoFilter dropdown.
|
||||
pub fn has_filter_button(&self, c: CellRef) -> bool {
|
||||
self.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && af.range.contains(c))
|
||||
|| self.tables.iter().any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && t.range.contains(c))
|
||||
}
|
||||
|
||||
/// Horizontal header space in unzoomed grid units (96 units per inch).
|
||||
/// Observed in Windows Excel 16.0 build 20326: AutoFit reserves 12.5 pt at
|
||||
/// 100-200% zoom, 25 pt at 50%, regardless of header row height. Below 100%
|
||||
/// the dropdown keeps its screen width. This is geometry, not font padding.
|
||||
pub fn filter_button_space(&self, c: CellRef) -> f32 {
|
||||
if self.has_filter_button(c) { (12.5 * 96.0 / 72.0) / (self.zoom as f32 / 100.0).clamp(0.1, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn col_width(&self, col: u32) -> f32 {
|
||||
match self.cols.get(&col) {
|
||||
Some(i) if i.hidden => 0.0,
|
||||
@@ -238,6 +252,17 @@ impl Sheet {
|
||||
pub fn is_row_hidden(&self, row: u32) -> bool {
|
||||
self.rows.get(&row).is_some_and(|r| r.hidden)
|
||||
}
|
||||
/// Whether `row` is hidden by an applied filter: a hidden row below the header of the
|
||||
/// sheet AutoFilter while it has criteria, or a hidden data row of a filtered table.
|
||||
/// Other hidden rows were hidden by hand.
|
||||
pub fn is_row_filtered(&self, row: u32) -> bool {
|
||||
if !self.is_row_hidden(row) {
|
||||
return false;
|
||||
}
|
||||
let in_sheet_filter =
|
||||
self.autofilter.as_ref().is_some_and(|af| !af.criteria.is_empty() && row > af.range.start.row && row <= af.range.end.row);
|
||||
in_sheet_filter || self.tables.iter().any(|t| !t.filter.is_empty() && t.data_range().is_some_and(|r| row >= r.start.row && row <= r.end.row))
|
||||
}
|
||||
pub fn is_col_hidden(&self, col: u32) -> bool {
|
||||
self.cols.get(&col).is_some_and(|r| r.hidden)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,16 @@ pub enum CalcMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalcSettings {
|
||||
pub mode: CalcMode,
|
||||
/// Enable iterative calculation: circular references are calculated repeatedly instead of
|
||||
/// reporting an error.
|
||||
pub iterative: bool,
|
||||
/// Maximum Iterations, 1 to [`CalcSettings::MAX_ITERATIONS`].
|
||||
pub max_iterations: u32,
|
||||
/// Maximum Change: iteration stops once no value in a circular reference changes by more.
|
||||
pub max_change: f64,
|
||||
/// Use precision as displayed.
|
||||
pub precision_as_displayed: bool,
|
||||
@@ -30,7 +35,32 @@ pub struct CalcSettings {
|
||||
|
||||
impl Default for CalcSettings {
|
||||
fn default() -> Self {
|
||||
CalcSettings { mode: CalcMode::Automatic, iterative: false, max_iterations: 100, max_change: 0.001, precision_as_displayed: false }
|
||||
CalcSettings {
|
||||
mode: CalcMode::Automatic,
|
||||
iterative: false,
|
||||
max_iterations: Self::DEFAULT_ITERATIONS,
|
||||
max_change: Self::DEFAULT_CHANGE,
|
||||
precision_as_displayed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CalcSettings {
|
||||
pub const DEFAULT_ITERATIONS: u32 = 100;
|
||||
pub const DEFAULT_CHANGE: f64 = 0.001;
|
||||
/// The largest Maximum Iterations Excel accepts.
|
||||
pub const MAX_ITERATIONS: u32 = 32_767;
|
||||
|
||||
/// Sets Maximum Iterations, kept within 1 to [`CalcSettings::MAX_ITERATIONS`].
|
||||
pub fn set_max_iterations(&mut self, n: u32) {
|
||||
self.max_iterations = n.clamp(1, Self::MAX_ITERATIONS);
|
||||
}
|
||||
|
||||
/// Sets Maximum Change (its magnitude); a value that isn't finite is ignored.
|
||||
pub fn set_max_change(&mut self, d: f64) {
|
||||
if d.is_finite() {
|
||||
self.max_change = d.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +109,10 @@ pub struct Workbook {
|
||||
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros.
|
||||
#[serde(default, with = "crate::b64::opt")]
|
||||
pub vba_project: Option<Vec<u8>>,
|
||||
/// Field separator of the CSV this workbook was read from, so saving it as CSV writes the same
|
||||
/// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub csv_delimiter: Option<u8>,
|
||||
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||||
@@ -107,6 +141,7 @@ impl Workbook {
|
||||
custom_lists: vec![],
|
||||
cell_styles: vec![],
|
||||
vba_project: None,
|
||||
csv_delimiter: None,
|
||||
preserved: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,8 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted
|
||||
}
|
||||
|
||||
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
|
||||
/// converted to a number first; other text goes through the text section (or is returned as is).
|
||||
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
|
||||
/// section, or are returned as is.
|
||||
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
|
||||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
@@ -305,7 +306,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
|
||||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
Value::Number(n) => n,
|
||||
Value::Bool(b) => return Ok(if b { "TRUE" } else { "FALSE" }.into()),
|
||||
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
|
||||
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
|
||||
Some(n) => n,
|
||||
None => return Ok(fmt.format_text(&t).text),
|
||||
|
||||
@@ -555,7 +555,9 @@ fn analyze(raw: Raw) -> Section {
|
||||
}
|
||||
}
|
||||
Tok::Slash => {
|
||||
let next_ok = matches!(toks.get(i + 1), Some(Tok::Digit(_) | Tok::FixedDenom(_)));
|
||||
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
|
||||
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|
||||
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
|
||||
let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. }));
|
||||
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
|
||||
fraction_slash = true;
|
||||
|
||||
+101
-43
@@ -138,7 +138,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out>
|
||||
}
|
||||
|
||||
/// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder
|
||||
/// takes all remaining digits.
|
||||
/// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no
|
||||
/// digits. A blank `?` pads with a space, and so does the thousands separator it would carry.
|
||||
fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) {
|
||||
let ds: Vec<char> = digits.chars().collect();
|
||||
let mut rem = ds.len();
|
||||
@@ -162,8 +163,14 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z
|
||||
if take == 0 {
|
||||
match ph {
|
||||
'0' => push_digit('0', &mut rev, &mut pos),
|
||||
_ if force_zero && leftmost && pos == 0 => push_digit('0', &mut rev, &mut pos),
|
||||
'?' => rev.push(' '),
|
||||
_ if force_zero && k == 0 => push_digit('0', &mut rev, &mut pos),
|
||||
'?' => {
|
||||
if thousands && pos > 0 && pos.is_multiple_of(3) {
|
||||
rev.push(' ');
|
||||
}
|
||||
rev.push(' ');
|
||||
pos += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
@@ -235,11 +242,14 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
}
|
||||
let toks = &sec.toks;
|
||||
let n = toks.len();
|
||||
// Pieces replace the token's own output; `before` is inserted ahead of a token.
|
||||
// Pieces replace the token's own output; `before` is inserted ahead of a token; `lits`
|
||||
// replaces a literal's text (fractions blank the literals of a hidden part).
|
||||
let mut pieces: Vec<String> = vec![String::new(); n];
|
||||
let mut before: Vec<String> = vec![String::new(); n + 1];
|
||||
let mut lits: Vec<Option<String>> = Vec::new();
|
||||
if sec.fraction {
|
||||
render_fraction(sec, x, &mut pieces);
|
||||
lits = vec![None; n];
|
||||
render_fraction(sec, x, &mut pieces, &mut lits);
|
||||
} else {
|
||||
let point = toks.iter().position(|t| matches!(t, Tok::Point));
|
||||
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
|
||||
@@ -321,7 +331,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
out.push(b);
|
||||
}
|
||||
match t {
|
||||
Tok::Lit(s) => out.push(s),
|
||||
Tok::Lit(s) => out.push(lits.get(i).and_then(Option::as_deref).unwrap_or(s)),
|
||||
Tok::Percent => out.push("%"),
|
||||
Tok::Fill(c) => out.fill(*c),
|
||||
Tok::At | Tok::General | Tok::Date(_) | Tok::Comma => {}
|
||||
@@ -370,29 +380,77 @@ fn approximate(x: f64, max_den: u64) -> (u64, u64) {
|
||||
if err_b < err_a { (pb, qb) } else { (p1, q1) }
|
||||
}
|
||||
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
|
||||
/// Display width of a token in a fraction section, for blanking a hidden part with spaces.
|
||||
fn shown_width(t: &Tok) -> usize {
|
||||
match t {
|
||||
Tok::Lit(s) | Tok::FixedDenom(s) => s.chars().count(),
|
||||
Tok::Digit(_) | Tok::Slash => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the literals in `range` with spaces (`spaces`) or nothing; placeholders and the bar
|
||||
/// in it get the same treatment through `pieces`.
|
||||
fn blank_range(toks: &[Tok], range: std::ops::Range<usize>, spaces: bool, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
for i in range {
|
||||
let Some(t) = toks.get(i) else { break };
|
||||
let s = if spaces { " ".repeat(shown_width(t)) } else { String::new() };
|
||||
match t {
|
||||
Tok::Lit(_) => {
|
||||
if let Some(l) = lits.get_mut(i) {
|
||||
*l = Some(s);
|
||||
}
|
||||
}
|
||||
Tok::Digit(_) | Tok::Slash | Tok::FixedDenom(_) => {
|
||||
if let Some(p) = pieces.get_mut(i) {
|
||||
*p = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
|
||||
/// placeholders ending at the last one before the bar, the whole part is every placeholder
|
||||
/// before it (literals between them stay), `sep` the literals between the two parts.
|
||||
///
|
||||
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
|
||||
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
|
||||
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
|
||||
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
let toks = &sec.toks;
|
||||
let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_)));
|
||||
let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return };
|
||||
let mut num_start = slash;
|
||||
while num_start > 0 && matches!(toks.get(num_start - 1), Some(Tok::Digit(_))) {
|
||||
let Some(num_last) = (0..slash).rev().find(|&i| is_digit(i)) else { return };
|
||||
let mut num_start = num_last;
|
||||
while num_start > 0 && is_digit(num_start - 1) {
|
||||
num_start -= 1;
|
||||
}
|
||||
let whole_idx = digit_indices(toks, 0..num_start);
|
||||
let num_idx = digit_indices(toks, num_start..slash);
|
||||
let mut den_end = slash + 1;
|
||||
let fixed_den: Option<u64> = match toks.get(slash + 1) {
|
||||
Some(Tok::FixedDenom(s)) => {
|
||||
den_end += 1;
|
||||
Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000))
|
||||
let num_idx = digit_indices(toks, num_start..num_last + 1);
|
||||
let sep_start = whole_idx.last().map_or(num_start, |&w| w + 1);
|
||||
let (fixed_den, den_start): (Option<u64>, usize) = match toks.get(slash + 1) {
|
||||
Some(Tok::FixedDenom(s)) => (Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000)), slash + 1),
|
||||
_ => {
|
||||
let mut i = slash + 1;
|
||||
while matches!(toks.get(i), Some(Tok::Lit(_))) {
|
||||
i += 1;
|
||||
}
|
||||
(None, i)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if fixed_den.is_none() {
|
||||
while matches!(toks.get(den_end), Some(Tok::Digit(_))) {
|
||||
let mut den_end = den_start;
|
||||
if fixed_den.is_some() {
|
||||
den_end += 1;
|
||||
} else {
|
||||
while is_digit(den_end) {
|
||||
den_end += 1;
|
||||
}
|
||||
}
|
||||
let den_idx = digit_indices(toks, slash + 1..den_end);
|
||||
let den_idx = digit_indices(toks, den_start..den_end);
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
|
||||
let (mut num, den) = match fixed_den {
|
||||
@@ -407,39 +465,39 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
|
||||
num %= den;
|
||||
}
|
||||
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
|
||||
let blank = has_whole && num == 0;
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, blank, pieces);
|
||||
let num_s = num.to_string();
|
||||
let den_s = den.to_string();
|
||||
if blank {
|
||||
let width = |idx: &[usize]| idx.len();
|
||||
let nw = width(&num_idx).max(1);
|
||||
if let Some(&first) = num_idx.first()
|
||||
&& let Some(p) = pieces.get_mut(first)
|
||||
{
|
||||
*p = " ".repeat(nw);
|
||||
}
|
||||
if let Some(p) = pieces.get_mut(slash) {
|
||||
*p = " ".into();
|
||||
}
|
||||
let dw = if fixed_den.is_some() { den_s.len() } else { den_idx.len() };
|
||||
if let Some(p) = pieces.get_mut(slash + 1) {
|
||||
*p = " ".repeat(dw);
|
||||
}
|
||||
// Literals between the whole part and the numerator stay as they are.
|
||||
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
|
||||
if has_whole && num == 0 && !num_zero_ph {
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces);
|
||||
let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx);
|
||||
blank_range(toks, sep_start..den_end, spaces, pieces, lits);
|
||||
return;
|
||||
}
|
||||
fill_int(toks, &num_idx, &num_s, false, true, pieces);
|
||||
if has_whole {
|
||||
// A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one.
|
||||
let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#')));
|
||||
let force = whole_s.is_empty() && num == 0 && !units_hash;
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces);
|
||||
let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit())));
|
||||
if !whole_shown {
|
||||
let spaces = has_q(&whole_idx) || has_q(&num_idx);
|
||||
blank_range(toks, sep_start..num_start, spaces, pieces, lits);
|
||||
}
|
||||
}
|
||||
fill_int(toks, &num_idx, &num.to_string(), false, true, pieces);
|
||||
if let Some(p) = pieces.get_mut(slash) {
|
||||
*p = "/".into();
|
||||
}
|
||||
let den_s = den.to_string();
|
||||
if fixed_den.is_some() {
|
||||
if let Some(p) = pieces.get_mut(slash + 1) {
|
||||
if let Some(p) = pieces.get_mut(den_start) {
|
||||
*p = den_s;
|
||||
}
|
||||
} else {
|
||||
// Denominator digits are left aligned; `?` pads with spaces after them.
|
||||
let dc: Vec<char> = den_s.chars().collect();
|
||||
// Denominator digits are zero padded to the `0` placeholders, then left aligned; `?`
|
||||
// pads with spaces after them.
|
||||
let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count();
|
||||
let padded = format!("{den_s:0>zeros$}");
|
||||
let dc: Vec<char> = padded.chars().collect();
|
||||
let count = den_idx.len();
|
||||
for (k, &ti) in den_idx.iter().enumerate() {
|
||||
let s = if k + 1 == count {
|
||||
|
||||
@@ -122,6 +122,82 @@ fn fractions() {
|
||||
assert_eq!(f("# ???/???", 0.5), " 1/2 ");
|
||||
}
|
||||
|
||||
/// Fraction sections with literal text, as Excel renders them (observed `TEXT()` results).
|
||||
#[test]
|
||||
fn fractions_with_literals() {
|
||||
let cases: &[(&str, f64, &str)] = &[
|
||||
// Literals between the parts and around the bar.
|
||||
(r"|#\:#/#|", 23.75, "|23:3/4|"),
|
||||
(r"|#\:#=/=#|", 23.75, "|23:3=/=4|"),
|
||||
(r"|#\:#=/=#|", -23.75, "-|23:3=/=4|"),
|
||||
(r"|#\:? ?=/=#|", 23.75, "|2:3 3=/=4|"),
|
||||
(r"|#-#-#\:#/#|", 23.75, "|-2-3:3/4|"),
|
||||
// A whole part without digits drops its separator (spaces next to a `?`).
|
||||
(r"|#\:#/#|", 0.75, "|3/4|"),
|
||||
(r"|#\:#/#|", -0.75, "-|3/4|"),
|
||||
(r"|#-#-#\:#/#|", 0.75, "|--3/4|"),
|
||||
(r"|#\:#=/=#|", 0.75, "|3=/=4|"),
|
||||
(r"|#\:0=/=?|", 0.75, "|3=/=4|"),
|
||||
(r"|#\:?=/=#|", 0.75, "| 3=/=4|"),
|
||||
(r"|?\:#=/=#|", 0.75, "| 3=/=4|"),
|
||||
(r"|?\:0=/=0|", -0.75, "-| 3=/=4|"),
|
||||
(r"|?\:?0=/=#|", 0.75, "| 3=/=4|"),
|
||||
(r"|0\:#=/=#|", 0.75, "|0:3=/=4|"),
|
||||
// A zero fraction hides separator, numerator, bar and denominator (spaces if any `?`).
|
||||
(r"|#\:#/#|", 0.0, "|0|"),
|
||||
(r"|#\:#/#|", 1.0, "|1|"),
|
||||
(r"|#\:#/#|", -1.0, "-|1|"),
|
||||
(r"|#-#-#\:#/#|", 0.0, "|--0|"),
|
||||
(r"|#-#-#\:#/#|", 1.0, "|--1|"),
|
||||
(r"|#\:#=/=0|", 0.0, "|0|"),
|
||||
(r"|0\:#=/=#|", 1.0, "|1|"),
|
||||
(r"|#\:#=/=?|", 0.0, "|0 |"),
|
||||
(r"|#\:?=/=0|", 1.0, "|1 |"),
|
||||
(r"|?\:#=/=#|", 0.0, "|0 |"),
|
||||
(r"|?\:#=/=#|", -1.0, "-|1 |"),
|
||||
(r"|#\:? ?=/=#|", 0.0, "|:0 |"),
|
||||
(r"|#\:? ?=/=?|", 1.0, "|:1 |"),
|
||||
// ... unless the numerator has a `0` placeholder: then it shows 0/1.
|
||||
(r"|#\:0=/=#|", 0.0, "|0=/=1|"),
|
||||
(r"|?\:0=/=?|", 0.0, "|0:0=/=1|"),
|
||||
(r"|0\:0=/=0|", 0.0, "|0:0=/=1|"),
|
||||
(r"|#\:0=/=#|", 1.0, "|1:0=/=1|"),
|
||||
(r"|?\:0=/=#|", -1.0, "-|1:0=/=1|"),
|
||||
(r"|#\:? ?0#/000", 0.0, "|:0 00/001"),
|
||||
(r"|#\:? ?0#/000", 1.0, "|:1 00/001"),
|
||||
// `0` denominator placeholders zero pad on the left.
|
||||
(r"|#\:? ?0#/000", 23.75, "|2:3 03/004"),
|
||||
(r"|#\:? ?#/#|", 23.75, "|2:3 3/4|"),
|
||||
// Literal text before the bar: still an improper fraction.
|
||||
(r"|#_#/#|", 3.75, "|15 /4|"),
|
||||
(r"|#_#/#|", -0.75, "-|3 /4|"),
|
||||
(r"|#_#/#|", 0.0, "|0 /1|"),
|
||||
(r"|#_#/#|", -1.0, "-|1 /1|"),
|
||||
(r"|#_#=/=?|", 3.75, "|15 =/=4|"),
|
||||
(r"|#_?=/=0|", 0.0, "|0 =/=1|"),
|
||||
(r"|#_?=/=?|", 1.0, "|1 =/=1|"),
|
||||
];
|
||||
for &(code, v, want) in cases {
|
||||
assert_eq!(f(code, v), want, "{code} with {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_pads_thousands_separator() {
|
||||
let cases: &[(&str, f64, &str)] = &[
|
||||
("?,???????", 1234567.0, " 1,234,567"),
|
||||
("?,????????", 1234567.0, " 1,234,567"),
|
||||
("?,?????????", 1234567.0, " 1,234,567"),
|
||||
("?,?????????", -1234567.0, "- 1,234,567"),
|
||||
("?,??????", 1234567.0, "1,234,567"),
|
||||
("?,????", 12345.0, "12,345"),
|
||||
("0,000000000", 1234567.0, "0,001,234,567"),
|
||||
];
|
||||
for &(code, v, want) in cases {
|
||||
assert_eq!(f(code, v), want, "{code} with {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literals_and_fill() {
|
||||
assert_eq!(f("\"$\"#,##0.00", 3.0), "$3.00");
|
||||
@@ -264,6 +340,21 @@ fn text_fn() {
|
||||
assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into()));
|
||||
assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value));
|
||||
assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into()));
|
||||
// Booleans go through the text section as TRUE/FALSE (observed Excel results).
|
||||
let cases: &[(bool, &str, &str)] = &[
|
||||
(true, ";;;@", "TRUE"),
|
||||
(true, ";;;\"hi\"", "hi"),
|
||||
(true, ";;;\"h\"\"I\"", "hI"),
|
||||
(false, ";;;\\h\\i", "hi"),
|
||||
(true, ";;;\\@@\\@", "@TRUE@"),
|
||||
(false, ";;;-@-", "-FALSE-"),
|
||||
(true, ";;;@-", "TRUE-"),
|
||||
(false, ";;;-@-@-", "-FALSE-FALSE-"),
|
||||
(true, "0.00", "TRUE"),
|
||||
];
|
||||
for &(b, code, want) in cases {
|
||||
assert_eq!(text_function(&Value::Bool(b), code, S), Ok(want.into()), "{code} with {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic xorshift for fuzzing.
|
||||
|
||||
@@ -28,6 +28,7 @@ zbus = "=5.19.0"
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
epaint_default_fonts = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
|
||||
@@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(250.0);
|
||||
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert("file.print", params.clone());
|
||||
out.close = true;
|
||||
if let Some(discover) = &app.services.printers {
|
||||
crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned());
|
||||
}
|
||||
let can_print = app.services.print_native.is_none() || app.printing.ready();
|
||||
if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked()
|
||||
&& app.commit_edit(0, 0, false, false)
|
||||
{
|
||||
app.print_now(params.clone());
|
||||
out.close = app.services.print_native.is_none();
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel`
|
||||
//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled
|
||||
//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}`
|
||||
//! - `app.open {path}`, `app.save {path?}`, `app.quit`
|
||||
//! - `app.open {path, password?}`, `app.save {path?}`, `app.quit`
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
@@ -184,6 +184,16 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
|
||||
("ui.dialog", "Open Dialog", ""),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
|
||||
(
|
||||
"ui.printSettings",
|
||||
"Print Settings",
|
||||
"{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting",
|
||||
),
|
||||
(
|
||||
"ui.printSubmit",
|
||||
"Submit Print Job",
|
||||
"existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog",
|
||||
),
|
||||
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"),
|
||||
("app.language.english", "Interface Language: English", ""),
|
||||
("app.language.japanese", "Interface Language: Japanese", ""),
|
||||
@@ -350,10 +360,17 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
"ui.screenshot" => return Outcome::Screenshot { path: s("path").map(str::to_string) },
|
||||
"app.open" => {
|
||||
let Some(path) = s("path") else { return err("missing `path`") };
|
||||
app.open_path(path);
|
||||
match app.message.take() {
|
||||
Some((_, m)) => err(m),
|
||||
None => ok(json!({"title": app.session.active().map(|d| d.display_title())})),
|
||||
// Not `open_path`: that asks for a missing password in a dialog, and programmatic
|
||||
// calls never open one. A password-protected workbook takes `password` instead.
|
||||
let r = app.run("file.open", json!({"path": path, "password": s("password")}));
|
||||
if let Ok(r) = &r {
|
||||
app.after_open(Some(path), r);
|
||||
}
|
||||
app.after_engine();
|
||||
match (r, app.message.take()) {
|
||||
(Err(e), _) => err(crate::clean_error(&e)),
|
||||
(Ok(_), Some((_, m))) => err(m),
|
||||
(Ok(_), None) => ok(json!({"title": app.session.active().map(|d| d.display_title())})),
|
||||
}
|
||||
}
|
||||
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
|
||||
@@ -486,4 +503,29 @@ mod tests {
|
||||
assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty());
|
||||
assert_eq!(filter_commands(sample(), None).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_command_discovery_documents_settings_and_submission() {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else {
|
||||
panic!("command discovery must return immediately");
|
||||
};
|
||||
assert_eq!(reply["ok"], true);
|
||||
let commands = reply["result"].as_array().unwrap();
|
||||
for (id, expected_docs) in [
|
||||
("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]),
|
||||
("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]),
|
||||
] {
|
||||
let command = commands.iter().find(|command| command["id"] == id).unwrap();
|
||||
assert_eq!(command["ui"], true);
|
||||
let docs = command["params"].as_str().unwrap();
|
||||
for expected in expected_docs {
|
||||
assert!(docs.contains(expected), "{id}: missing {expected}");
|
||||
}
|
||||
}
|
||||
assert!(!app.printing.busy(), "discovery must not submit a job");
|
||||
assert!(app.backstage.is_none(), "discovery must not open settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,23 @@ impl Dialog {
|
||||
vec![Password { key: "password", label: "Password:" }],
|
||||
json!({"on": false}),
|
||||
),
|
||||
"openPassword" => {
|
||||
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
||||
// on the web) and goes back to `file.open` with the password.
|
||||
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
||||
let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
|
||||
// An empty password from the start, so OK on the untouched field says "incorrect"
|
||||
// rather than asking for the password again.
|
||||
let mut values = params.clone();
|
||||
values["password"] = json!("");
|
||||
Dialog::form(
|
||||
"openPassword",
|
||||
"Password",
|
||||
"file.open",
|
||||
vec![Note(format!("'{file}' is protected.")), Password { key: "password", label: "Password:" }],
|
||||
values,
|
||||
)
|
||||
}
|
||||
"unprotectSheet" => Dialog::form(
|
||||
"unprotectSheet",
|
||||
"Unprotect Sheet",
|
||||
@@ -1101,12 +1118,20 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
open = false;
|
||||
if d.name == "solver" {
|
||||
app.open_dialog("solverResults", r);
|
||||
} else if d.name == "openPassword" {
|
||||
let path = d.values.get("path").and_then(Json::as_str).map(str::to_string);
|
||||
app.after_open(path.as_deref(), &r);
|
||||
}
|
||||
}
|
||||
// Data Analysis asks before replacing data in its output range.
|
||||
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
|
||||
d.values.insert("askOverwrite".into(), json!(true));
|
||||
}
|
||||
// A wrong password: say so, clear the field and stay open for another try.
|
||||
Err(e) if d.name == "openPassword" => {
|
||||
d.values.insert("password".into(), json!(""));
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
}
|
||||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||||
}
|
||||
} else if confirm {
|
||||
|
||||
@@ -213,7 +213,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
|
||||
{
|
||||
let mut end = toks[j].end;
|
||||
let mut range = RangeRef::cell(c);
|
||||
if let (Some(Tok::Colon), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok))
|
||||
if let (Some(Tok::Colon | Tok::Trim(_)), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok))
|
||||
&& let Some(c2) = CellRef::parse(w2)
|
||||
{
|
||||
range = RangeRef::new(c, c2);
|
||||
|
||||
@@ -392,6 +392,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
}
|
||||
ui.label(egui::RichText::new(")").font(theme::ui_font(12.0)));
|
||||
});
|
||||
// The description too, so values an argument takes (DATEDIF's units, #363) show
|
||||
// while it's typed; wrapped to the hint's width.
|
||||
if let Some(desc) = crate::formula_locale::description(&name, ed.locale) {
|
||||
ui.set_max_width(ui.min_rect().width().max(220.0));
|
||||
ui.separator();
|
||||
ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+107
-16
@@ -752,6 +752,27 @@ pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so
|
||||
/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the
|
||||
/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows.
|
||||
/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but
|
||||
/// downward text at the top, where it starts reading.
|
||||
pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 {
|
||||
let rot = egui::emath::Rot2::from_angle(angle);
|
||||
let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2()));
|
||||
let x = match h {
|
||||
HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0,
|
||||
HAlign::Right => rect.right() - pad - indent - turned.width(),
|
||||
_ => rect.left() + pad + indent,
|
||||
};
|
||||
let y = match v {
|
||||
VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0,
|
||||
VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(),
|
||||
_ => rect.top() + pad,
|
||||
};
|
||||
pos2(x, y) - turned.min.to_vec2()
|
||||
}
|
||||
|
||||
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
|
||||
/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction
|
||||
/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index
|
||||
@@ -934,6 +955,10 @@ pub(crate) fn paint_quadrant(
|
||||
if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Align and clip header text inside the content area, away from the dropdown.
|
||||
let filter_space = sh.filter_button_space(c) * z;
|
||||
let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom()));
|
||||
let rect = &text_rect;
|
||||
let st = &lk.style;
|
||||
let size = st.font.size * 96.0 / 72.0 * z;
|
||||
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
|
||||
@@ -1012,7 +1037,13 @@ pub(crate) fn paint_quadrant(
|
||||
}
|
||||
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
|
||||
let mut clip = *rect;
|
||||
if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
|
||||
if filter_space == 0.0
|
||||
&& !numeric
|
||||
&& !matches!(v, Value::Number(_))
|
||||
&& st.align.rotation.abs() != 90
|
||||
&& w > rect.width() - 2.0 * pad
|
||||
&& in_merge(c).is_none()
|
||||
{
|
||||
let mut right = rect.right();
|
||||
let mut left = rect.left();
|
||||
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
|
||||
@@ -1060,13 +1091,7 @@ pub(crate) fn paint_quadrant(
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
// Upward text grows up and right from the bottom-left corner; downward text grows down
|
||||
// and left from its anchor, so that anchor sits one block-height in from the top-left.
|
||||
let pos = if angle > 0.0 {
|
||||
pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad)
|
||||
} else {
|
||||
pos2(rect.left() + pad, rect.bottom() - pad)
|
||||
};
|
||||
let pos = rotated_text_pos(*rect, galley.size(), angle, st.align.h, st.align.v, pad, indent);
|
||||
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
|
||||
shape.angle = angle;
|
||||
cp.add(shape);
|
||||
@@ -1170,8 +1195,10 @@ pub(crate) fn paint_quadrant(
|
||||
|
||||
pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
|
||||
let rect = geo.cell_rect(sh, c);
|
||||
let s = (rect.height() - 4.0).clamp(10.0, 18.0);
|
||||
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
|
||||
// Keep the button within the same header slot that AutoFit reserves.
|
||||
let pad = 2.0 * geo.z;
|
||||
let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0));
|
||||
Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s))
|
||||
}
|
||||
|
||||
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
||||
@@ -2226,12 +2253,7 @@ fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> {
|
||||
|
||||
fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool {
|
||||
let c = geo.cell_at(sh, p);
|
||||
let header = sh.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && c.col >= af.range.start.col && c.col <= af.range.end.col)
|
||||
|| sh
|
||||
.tables
|
||||
.iter()
|
||||
.any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && c.col >= t.range.start.col && c.col <= t.range.end.col);
|
||||
header && filter_button_rect(geo, sh, c).contains(p)
|
||||
sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p)
|
||||
}
|
||||
|
||||
fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::model::Anchor> {
|
||||
@@ -3072,6 +3094,75 @@ mod tests {
|
||||
let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z);
|
||||
assert!(y_bottom_ovf >= rect.top());
|
||||
}
|
||||
|
||||
/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui
|
||||
/// draws a rotated `TextShape`.
|
||||
fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect {
|
||||
let rot = egui::emath::Rot2::from_angle(angle);
|
||||
let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v);
|
||||
Rect::from_points(&pts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_text_box_follows_the_cell_alignment() {
|
||||
// A tall, narrow merged block (one 3-character column over four 60pt rows).
|
||||
let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0));
|
||||
let size = vec2(90.0, 15.0);
|
||||
let pad = 2.5;
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
|
||||
for deg in [90i16, -90, 45, -45] {
|
||||
let angle = -(deg as f32).to_radians();
|
||||
for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] {
|
||||
for h in [HAlign::Left, HAlign::Center, HAlign::Right] {
|
||||
let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle);
|
||||
let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}");
|
||||
assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}");
|
||||
assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}");
|
||||
// A narrow column can't hold a 45° line across: only check where it fits.
|
||||
let fits_x = bb.width() <= rect.width() - 2.0 * pad;
|
||||
match h {
|
||||
HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"),
|
||||
HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"),
|
||||
_ => assert!(near(bb.center().x, rect.center().x), "{what}"),
|
||||
}
|
||||
if fits_x {
|
||||
assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}");
|
||||
}
|
||||
match v {
|
||||
VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"),
|
||||
VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"),
|
||||
// Bottom is the default: downward text keeps starting at the top, as before.
|
||||
_ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"),
|
||||
_ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_text_with_default_alignment_keeps_its_place() {
|
||||
let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0));
|
||||
let size = vec2(90.0, 15.0);
|
||||
let pad = 2.5;
|
||||
let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01;
|
||||
// Upward text starts at the bottom-left corner, downward text one block-height in from the top-left.
|
||||
let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0);
|
||||
assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}");
|
||||
let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0);
|
||||
assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_text_with_middle_align_is_centred_in_a_merged_block() {
|
||||
// Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom.
|
||||
let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0));
|
||||
let size = vec2(90.0, 15.0);
|
||||
let angle = -std::f32::consts::FRAC_PI_2;
|
||||
let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle);
|
||||
assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}");
|
||||
assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
mod cs;
|
||||
mod de;
|
||||
mod es;
|
||||
mod fr;
|
||||
mod ko;
|
||||
mod pt;
|
||||
mod ru;
|
||||
@@ -31,10 +32,11 @@ pub enum Language {
|
||||
PtBr,
|
||||
De,
|
||||
Cs,
|
||||
Fr,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 9] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs];
|
||||
pub const ALL: [Self; 10] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr];
|
||||
|
||||
/// The language's own name, shown in the switcher.
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -48,6 +50,7 @@ impl Language {
|
||||
Self::PtBr => "Português (Brasil)",
|
||||
Self::De => "Deutsch",
|
||||
Self::Cs => "Čeština",
|
||||
Self::Fr => "Français",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +66,7 @@ impl Language {
|
||||
Self::PtBr => "pt",
|
||||
Self::De => "de",
|
||||
Self::Cs => "cs",
|
||||
Self::Fr => "fr",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ impl Language {
|
||||
"pt" => Some(Self::PtBr),
|
||||
"de" => Some(Self::De),
|
||||
"cs" => Some(Self::Cs),
|
||||
"fr" => Some(Self::Fr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -117,6 +122,7 @@ impl Language {
|
||||
Self::PtBr => pt::translate(text),
|
||||
Self::De => de::translate(text),
|
||||
Self::Cs => cs::translate(text),
|
||||
Self::Fr => fr::translate(text),
|
||||
};
|
||||
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
|
||||
}
|
||||
@@ -580,6 +586,7 @@ mod tests {
|
||||
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
|
||||
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
|
||||
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
|
||||
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("Data"), "データ");
|
||||
assert_eq!(Language::Zh.tr("Data"), "数据");
|
||||
@@ -591,6 +598,8 @@ mod tests {
|
||||
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::De.tr("Data"), "Daten");
|
||||
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Fr.tr("Data"), "Données");
|
||||
assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::PtBr.tr("Date"), "Data");
|
||||
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
@@ -642,6 +651,9 @@ mod tests {
|
||||
assert_eq!(Language::from_tag("cs-CZ"), Some(Language::Cs));
|
||||
assert_eq!(Language::from_tag("cs_CZ.UTF-8"), Some(Language::Cs));
|
||||
assert_eq!(Language::parse("cs"), Some(Language::Cs));
|
||||
assert_eq!(Language::from_tag("fr-FR"), Some(Language::Fr));
|
||||
assert_eq!(Language::from_tag("fr_CA"), Some(Language::Fr));
|
||||
assert_eq!(Language::parse("fr"), Some(Language::Fr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! French translations for the interface strings in i18n.rs.
|
||||
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Different First Page" => "Première page différente",
|
||||
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
|
||||
"Paste" => "Coller",
|
||||
"Paste Values" => "Coller les valeurs",
|
||||
"Paste Formulas" => "Coller les formules",
|
||||
"Paste Formatting" => "Coller la mise en forme",
|
||||
"Transpose" => "Transposer",
|
||||
"Paste Link" => "Coller la liaison",
|
||||
"Paste Special…" => "Collage spécial…",
|
||||
"Insert Copied Cells" => "Insérer les cellules coupées",
|
||||
"Insert Copied Cells…" => "Coller en insérant",
|
||||
"Insert Cut Cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
|
||||
"Insert Paste" => "Taille de l’interface",
|
||||
"Cut (⌘X)" => "Couper (⌘X)",
|
||||
"Copy (⌘C)" => "Copier (⌘C)",
|
||||
"Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)",
|
||||
"Bold (⌘B)" => "Gras (⌘B)",
|
||||
"Italic (⌘I)" => "Italique (⌘I)",
|
||||
"Underline (⌘U)" => "Souligné (⌘U)",
|
||||
"Double Underline" => "Double souligné",
|
||||
"Strikethrough" => "Barré",
|
||||
"Underline" => "Souligné",
|
||||
"Increase Font Size" => "Augmenter la taille de la police",
|
||||
"Decrease Font Size" => "Réduire la taille de la police",
|
||||
"Wrap Text" => "Renvoyer à la ligne",
|
||||
"Merge & Center" => "Fusionner et centrer",
|
||||
"Merge Cells" => "Fusionner les cellules",
|
||||
"Merge Across" => "Fusionner horizontalement",
|
||||
"Unmerge Cells" => "Dissocier les cellules",
|
||||
"Fill Color" => "Couleur de remplissage",
|
||||
"Font Color" => "Couleur de police",
|
||||
"Borders" => "Bordures",
|
||||
"Top\nAlign" => "Aligner en haut",
|
||||
"Middle\nAlign" => "Aligner au centre",
|
||||
"Bottom\nAlign" => "Aligner en bas",
|
||||
"Left" => "Gauche",
|
||||
"Right" => "Droite",
|
||||
"Center" => "Centré",
|
||||
"Horizontal" => "Horizontal",
|
||||
"Vertical Text" => "Texte vertical",
|
||||
"Decrease Indent" => "Diminuer le retrait",
|
||||
"Increase Indent" => "Augmenter le retrait",
|
||||
"Orientation" => "Orientation",
|
||||
"Page Layout|Orientation" => "Orientation",
|
||||
"Angle Counterclockwise" => "Inclinaison vers la gauche",
|
||||
"Angle Clockwise" => "Inclinaison vers la droite",
|
||||
"Rotate Text Up" => "Pivoter le texte vers le haut",
|
||||
"Rotate Text Down" => "Pivoter le texte vers le bas",
|
||||
"Accounting Number Format" => "Format de nombre Comptabilité",
|
||||
"Percent Style" => "Style de pourcentage",
|
||||
"Comma Style" => "Style de virgule",
|
||||
"Increase Decimal" => "Ajouter une décimale",
|
||||
"Decrease Decimal" => "Retirer une décimale",
|
||||
"More Accounting Formats…" => "Autres formats comptables…",
|
||||
"Conditional\nFormatting" => "Mise en forme conditionnelle",
|
||||
"Conditional Formatting" => "Mise en forme conditionnelle",
|
||||
"Format\nas Table" => "Mettre sous forme de tableau",
|
||||
"Cell\nStyles" => "Styles de cellules",
|
||||
"Format Cells…" => "Format de cellules…",
|
||||
"Format Cell Alignment…" => "Format d’alignement de cellule…",
|
||||
"Format\nPane" => "Volet Format",
|
||||
"Format" => "Format",
|
||||
"Insert" => "Insertion",
|
||||
"Insert Cells…" => "Insérer des cellules…",
|
||||
"Delete" => "Supprimer",
|
||||
"Delete Cells…" => "Supprimer des cellules…",
|
||||
"Insert Sheet Rows" => "Insérer des lignes de feuille",
|
||||
"Insert Sheet Columns" => "Insérer des colonnes de feuille",
|
||||
"Delete Sheet Rows" => "Supprimer des lignes de feuille",
|
||||
"Delete Sheet Columns" => "Supprimer des colonnes de feuille",
|
||||
"Row Height…" => "Hauteur de ligne…",
|
||||
"AutoFit Row Height" => "Ajustement automatique de la hauteur de ligne",
|
||||
"Column Width…" => "Largeur de colonne…",
|
||||
"AutoFit Column Width" => "Ajustement automatique de la largeur de colonne",
|
||||
"Default Width…" => "Largeur par défaut…",
|
||||
"Hide Rows" => "Masquer les lignes",
|
||||
"Unhide Rows" => "Afficher les lignes",
|
||||
"Hide Columns" => "Masquer les colonnes",
|
||||
"Unhide Columns" => "Afficher les colonnes",
|
||||
"Lock Cell" => "Verrouiller la cellule",
|
||||
"Clear" => "Effacer",
|
||||
"Clear All" => "Effacer tout",
|
||||
"Clear Formats" => "Effacer les formats",
|
||||
"Clear Contents" => "Effacer le contenu",
|
||||
"Clear Comments and Notes" => "Effacer les commentaires et les notes",
|
||||
"Clear Hyperlinks" => "Effacer les liens hypertexte",
|
||||
"AutoSum" => "Somme automatique",
|
||||
"AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)",
|
||||
"Flash\nFill" => "Remplissage instantané",
|
||||
"Flash Fill" => "Remplissage instantané",
|
||||
"Fill" => "Remplir",
|
||||
"Clear Print Area" => "Effacer la zone d’impression",
|
||||
"Find &\nSelect" => "Rechercher et sélectionner",
|
||||
"Find…" => "Rechercher…",
|
||||
"Replace…" => "Remplacer…",
|
||||
"Go To…" => "Atteindre…",
|
||||
"Go To Special…" => "Atteindre spécial…",
|
||||
"PivotTable" => "Tableau croisé dynamique",
|
||||
"Recommended\nCharts" => "Graphiques recommandés",
|
||||
"Table" => "Tableau",
|
||||
"Pictures" => "Images",
|
||||
"Shapes" => "Formes",
|
||||
"Icons" => "Icônes",
|
||||
"Text\nBox" => "Zone de texte",
|
||||
"Comment" => "Commentaire",
|
||||
"New\nComment" => "Nouveau commentaire",
|
||||
"New Note" => "Nouvelle note",
|
||||
"Show/Hide Note" => "Afficher/masquer la note",
|
||||
"Header &\nFooter" => "En-tête et pied de page",
|
||||
"Text to\nColumns" => "Convertir",
|
||||
"Link" => "Lien",
|
||||
"Symbol" => "Symbole",
|
||||
"Sparklines" => "Sparklines",
|
||||
"Checkbox" => "Cliquez pour ajouter des données",
|
||||
"Insert checkboxes in the selected cells" => "Mots-clés",
|
||||
"Header & Footer" => "En-tête et pied de page",
|
||||
"Draw with ink" => "Dessiner avec l’encre",
|
||||
"Eraser" => "Gomme",
|
||||
"Ink to\nShape" => "Encre vers forme",
|
||||
"Select\nObjects" => "Sélection d’objets",
|
||||
"Themes" => "Thèmes",
|
||||
"Margins" => "Marges",
|
||||
"Size" => "Taille",
|
||||
"Print\nArea" => "Zone d’impression",
|
||||
"Set Print Area" => "Définir la zone d’impression",
|
||||
"Breaks" => "Sauts",
|
||||
"Insert Page Break" => "Insérer un saut de page",
|
||||
"Remove Page Break" => "Supprimer le saut de page",
|
||||
"Reset All Page Breaks" => "Réinitialiser tous les sauts de page",
|
||||
"Print\nTitles" => "Titres à imprimer",
|
||||
"Sheet\nRight-to-Left" => "Afficher la barre de formule",
|
||||
"Sheet Right-to-Left" => "Feuilles",
|
||||
"Narrow" => "Étroites",
|
||||
"Wide" => "Larges",
|
||||
"Normal" => "Normales",
|
||||
"Portrait" => "Portrait",
|
||||
"Landscape" => "Paysage",
|
||||
"Custom Margins…" => "Marges personnalisées…",
|
||||
"Insert\nFunction" => "Insérer une fonction",
|
||||
"Name\nManager" => "Gestionnaire de noms",
|
||||
"Define Name" => "Définir un nom",
|
||||
"Use in Formula" => "Utiliser dans la formule",
|
||||
"Create from Selection" => "Créer à partir de la sélection",
|
||||
"Trace Precedents" => "Repérer les antécédents",
|
||||
"Trace Dependents" => "Repérer les dépendants",
|
||||
"Remove Arrows" => "Supprimer les flèches",
|
||||
"Show Formulas" => "Afficher les formules",
|
||||
"Error Checking" => "Vérification des erreurs",
|
||||
"Evaluate Formula" => "Évaluation de formule",
|
||||
"Watch Window" => "Fenêtre Espion",
|
||||
"Calculation\nOptions" => "Options de calcul",
|
||||
"Calculate Now" => "Calculer maintenant",
|
||||
"Calculate Sheet" => "Calculer la feuille",
|
||||
"Automatic" => "Automatique",
|
||||
"Automatic Except for Data Tables" => "Automatique sauf pour les tables de données",
|
||||
"Manual" => "Manuel",
|
||||
"Sort &\nFilter" => "Trier et filtrer",
|
||||
"Sort" => "Trier",
|
||||
"Sort A to Z" => "Trier de A à Z",
|
||||
"Sort Z to A" => "Trier de Z à A",
|
||||
"Custom Sort…" => "Tri personnalisé…",
|
||||
"Filter" => "Filtrer",
|
||||
"Reapply" => "Réappliquer",
|
||||
"Get Data\n(Text/CSV)" => "Obtenir des données (Texte/CSV)",
|
||||
"Remove\nDuplicates" => "Supprimer les doublons",
|
||||
"Data\nValidation" => "Validation des données",
|
||||
"Data Validation" => "Validation des données",
|
||||
"Data Validation…" => "Validation des données…",
|
||||
"Circle Invalid Data" => "Cerner les données non valides",
|
||||
"Clear Validation" => "Effacer la validation",
|
||||
"Group" => "Grouper",
|
||||
"Ungroup" => "Dissocier",
|
||||
"Show Detail" => "Titre",
|
||||
"Hide Detail" => "Insérer les cellules copiées",
|
||||
"Subtotal" => "Sous-total",
|
||||
"Data\nAnalysis" => "Description",
|
||||
"Data Analysis" => "Analyse\nde données",
|
||||
"Solver" => "Page web (.html)",
|
||||
"What-If\nAnalysis" => "Analyse scénarios",
|
||||
"Data Table…" => "Table de données…",
|
||||
"Scenario Manager…" => "Gestionnaire de scénarios…",
|
||||
"Goal Seek…" => "Valeur cible…",
|
||||
"Forecast\nSheet" => "Aller au pied de page",
|
||||
"Spelling" => "Orthographe",
|
||||
"Check\nAccessibility" => "Vérifier l’accessibilité",
|
||||
"Threaded Comments" => "Commentaires threadés",
|
||||
"Show\nComments" => "Afficher les commentaires",
|
||||
"Previous" => "Imprimer les feuilles actives",
|
||||
"Next" => "Aucun classeur récent pour le moment.",
|
||||
"Previous Comment" => "Imprimer tout le classeur",
|
||||
"Next Comment" => "Aucun classeur n’est ouvert.",
|
||||
"Show All Notes" => "Objet",
|
||||
"Convert to Comments" => "Créer un PDF",
|
||||
"Protect\nWorkbook" => "Protéger le classeur",
|
||||
"Allow Edit\nRanges" => "Animations",
|
||||
"Allow Edit Ranges" => "Autoriser la\nmodification des plages",
|
||||
"Unprotect\nSheet" => "Ôter la protection de la feuille",
|
||||
"Protect Sheet…" => "Protéger la feuille…",
|
||||
"Comments" => "Commentaires",
|
||||
"Notes" => "Notes",
|
||||
"Page Break\nPreview" => "Aperçu des sauts de page",
|
||||
"Page\nLayout" => "Mise en page",
|
||||
"Formula Bar" => "Barre de formule",
|
||||
"Gridlines" => "Quadrillage",
|
||||
"Headings" => "En-têtes",
|
||||
"Zoom" => "Zoom",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "Zoom sur la sélection",
|
||||
"Freeze\nPanes" => "Figer les volets",
|
||||
"Freeze Panes" => "Figer les volets",
|
||||
"Freeze Top Row" => "Figer la ligne supérieure",
|
||||
"Freeze First Column" => "Figer la première colonne",
|
||||
"Unfreeze Panes" => "Libérer les volets",
|
||||
"Dark Mode" => "Mode sombre",
|
||||
"Display theme" => "Thème d’affichage",
|
||||
"Animations" => "Auteur",
|
||||
"Glide the selection outline to the cell you move to instead of jumping there" => "Aller à l’en-tête",
|
||||
"System" => "Système",
|
||||
"Light" => "Clair",
|
||||
"Dark" => "Sombre",
|
||||
"Interface language" => "Langue de l’interface",
|
||||
"Interface size" => "Emplacement",
|
||||
"Auto" => "Retour",
|
||||
"Drag to freeze columns" => "Faites glisser pour figer les lignes",
|
||||
"Drag to freeze rows" => "Activer le calcul itératif",
|
||||
"Command\nPalette" => "Palette de commandes",
|
||||
"Agent\nControl" => "Contrôle de l’agent",
|
||||
"Action\nJournal" => "Journal des actions",
|
||||
"About\nGridCraft" => "À propos de GridCraft",
|
||||
"Share" => "Partager",
|
||||
"Add Chart\nElement" => "Ajouter un élément de graphique",
|
||||
"Change\nChart Type" => "Modifier le type de graphique",
|
||||
"Switch\nRow/Column" => "Permuter ligne/colonne",
|
||||
"Select\nData" => "Nom de la\nfeuille",
|
||||
"Data Labels: None" => "Étiquettes de données : Aucune",
|
||||
"Data Labels: Show" => "Étiquettes de données : Afficher",
|
||||
"Legend: None" => "Légende : Aucune",
|
||||
"Legend: Bottom" => "Légende : Bas",
|
||||
"Legend: Top" => "Légende : Haut",
|
||||
"Legend: Right" => "Légende : Droite",
|
||||
"Gridlines: None" => "Quadrillage : Aucun",
|
||||
"Gridlines: Show" => "Quadrillage : Afficher",
|
||||
"Chart\nTitle" => "Titre du graphique",
|
||||
"Delete\nChart" => "Supprimer le graphique",
|
||||
"Convert\nto Range" => "Convertir en plage",
|
||||
"Table\nStyles" => "Styles de tableau",
|
||||
"Top 10 Items" => "10 premiers éléments",
|
||||
"Top 10%" => "10 % les plus élevés",
|
||||
"Bottom 10 Items" => "10 derniers éléments",
|
||||
"Bottom 10%" => "10 % les moins élevés",
|
||||
"Above Average" => "Au-dessus de la moyenne",
|
||||
"Below Average" => "En dessous de la moyenne",
|
||||
"Clear Rules from Entire Sheet" => "Effacer les règles de la feuille entière",
|
||||
"Clear Rules from Selected Cells" => "Effacer les règles des cellules sélectionnées",
|
||||
"Constants" => "Constantes",
|
||||
"Data Bars" => "Barres de données",
|
||||
"Color Scales" => "Échelles de couleurs",
|
||||
"Icon Sets" => "Jeux d’icônes",
|
||||
"Home" => "Accueil",
|
||||
"Draw" => "Dessin",
|
||||
"Page Layout" => "Mise en page",
|
||||
"Formulas" => "Formules",
|
||||
"Data" => "Données",
|
||||
"Review" => "Révision",
|
||||
"View" => "Affichage",
|
||||
"Automate" => "Automatiser",
|
||||
"Table Design" => "Création de tableau",
|
||||
"Chart Design" => "Création de graphique",
|
||||
"Ready" => "Prêt",
|
||||
"Enter" => "Entrée",
|
||||
"Edit" => "Modifier",
|
||||
"Point" => "Point",
|
||||
"Calculate" => "Calculer",
|
||||
"Select destination and press Enter or choose Paste" => "Sélectionnez la destination et appuyez sur Entrée ou choisissez Coller",
|
||||
"General" => "Standard",
|
||||
"Number" => "Nombre",
|
||||
"Currency" => "Monétaire",
|
||||
"Accounting" => "Comptabilité",
|
||||
"Date" => "Date",
|
||||
"Time" => "Heure",
|
||||
"Percentage" => "Pourcentage",
|
||||
"Fraction" => "Fraction",
|
||||
"Scientific" => "Scientifique",
|
||||
"Text" => "Texte",
|
||||
"Special" => "Spécial",
|
||||
"Custom" => "Personnalisée",
|
||||
"Short Date" => "Date courte",
|
||||
"Long Date" => "Date longue",
|
||||
"More Number Formats…" => "Autres formats numériques…",
|
||||
"Financial" => "Financières",
|
||||
"Logical" => "Logiques",
|
||||
"Date &\nTime" => "Date et heure",
|
||||
"Lookup &\nReference" => "Recherche et référence",
|
||||
"Math &\nTrig" => "Maths et trigonométrie",
|
||||
"More\nFunctions" => "Autres fonctions",
|
||||
"Sheet Options|View" => "Affichage",
|
||||
"Print" => "Impression",
|
||||
"Click to add data" => "Fermer",
|
||||
"Add header" => "Autoriser la modification des plages",
|
||||
"Add footer" => "Ajouter un en-tête",
|
||||
"(none)" => "(aucun)",
|
||||
"Header" => "Informations",
|
||||
"Footer" => "Feuille de\nprévision",
|
||||
"Page\nNumber" => "Page précédente",
|
||||
"Number\nof Pages" => "Général",
|
||||
"Current\nDate" => "Heure\nactuelle",
|
||||
"Current\nTime" => "Analyse de données",
|
||||
"File\nPath" => "Ajuster la feuille sur une seule page",
|
||||
"File\nName" => "Chemin\ndu fichier",
|
||||
"Sheet\nName" => "Afficher le détail",
|
||||
"Go to\nHeader" => "Masquer le détail",
|
||||
"Go to\nFooter" => "En-tête",
|
||||
"Page Number" => "Numéro\nde page",
|
||||
"Number of Pages" => "Options",
|
||||
"Current Date" => "Heure actuelle",
|
||||
"Current Time" => "Date\nactuelle",
|
||||
"File Path" => "Nom du\nfichier",
|
||||
"File Name" => "Chemin du fichier",
|
||||
"Sheet Name" => "Feuille de\ndroite à gauche",
|
||||
"Go to Header" => "Aller à\nl’en-tête",
|
||||
"Go to Footer" => "Aller au\npied de page",
|
||||
"Ruler" => "Enregistrer sous",
|
||||
"File" => "Nom du fichier",
|
||||
"Back" => "Classeur vide",
|
||||
"New" => "Page suivante",
|
||||
"Open" => "Enregistrement",
|
||||
"Info" => "Insérer les cellules copiées…",
|
||||
"Save" => "Type de fichier",
|
||||
"Save As" => "Enregistrer la feuille sous forme de document PDF en conservant sa mise en page.",
|
||||
"Export" => "Fichier",
|
||||
"Close" => "Société",
|
||||
"Options" => "PDF (*.pdf)",
|
||||
"Blank workbook" => "Parcourir…",
|
||||
"Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
|
||||
"No recent workbooks yet." => "Rien à imprimer.",
|
||||
"More workbooks" => "Commentaire suivant",
|
||||
"Browse…" => "CSV (séparé par des virgules)",
|
||||
"Not saved yet" => "Nombre\nde pages",
|
||||
"Location" => "Nouveau",
|
||||
"Sheets" => "Solveur",
|
||||
"Protect Workbook" => "Enregistrer",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "Afficher toutes les notes",
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "Suivant",
|
||||
"Properties" => "Règle",
|
||||
"Title" => "Titre",
|
||||
"Subject" => "Calcul du classeur",
|
||||
"Author" => "Automatique",
|
||||
"Company" => "Convertir en commentaires",
|
||||
"Keywords" => "Plus de classeurs",
|
||||
"Description" => "Télécharger",
|
||||
"No workbook is open." => "Nombre de pages",
|
||||
"Save as type" => "Enregistrer le classeur sous forme de page web.",
|
||||
"Excel Workbook (*.xlsx)" => "Exporter",
|
||||
"CSV UTF-8 (Comma delimited) (*.csv)" => "Les paramètres de calcul sont enregistrés avec le classeur.",
|
||||
"Web Page (*.html)" => "Page web (*.html)",
|
||||
"PDF (*.pdf)" => "Page {page} sur {pages}",
|
||||
"Download" => "Faites glisser pour figer les colonnes",
|
||||
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "Ajouter un pied de page",
|
||||
"Save the workbook under another name or in another folder." => "Feuille de droite à gauche",
|
||||
"Print Active Sheets" => "Protéger le classeur",
|
||||
"Print Entire Workbook" => "Récents",
|
||||
"Fit Sheet on One Page" => "Pied de page",
|
||||
"Page Setup…" => "Précédent",
|
||||
"Page {page} of {pages}" => "Commentaire précédent",
|
||||
"Nothing to print." => "Ouvrir",
|
||||
"Previous page" => "Propriétés",
|
||||
"Next page" => "Pas encore enregistré",
|
||||
"Create PDF" => "Date actuelle",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "Sélectionner\nles données",
|
||||
"Change File Type" => "Case à cocher",
|
||||
"CSV (Comma delimited)" => "CSV UTF-8 (séparé par des virgules) (*.csv)",
|
||||
"Save the active sheet as comma-separated text." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.",
|
||||
"Web Page (.html)" => "Page web (.html)",
|
||||
"Save the workbook as a web page." => "Nom de la feuille",
|
||||
"Options|General" => "Numéro de page",
|
||||
"Options|Save" => "Mise en page…",
|
||||
"Show formula bar" => "Page web (*.html)",
|
||||
"Save .xlsx workbooks automatically after every change" => "Enregistrer la feuille active sous forme de texte séparé par des virgules.",
|
||||
"Workbook calculation" => "Calcul du classeur",
|
||||
"Enable iterative calculation" => "Classeur Excel (*.xlsx)",
|
||||
"Calculation settings are saved with the workbook." => "Modifier le type de fichier",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions.
|
||||
//!
|
||||
//! The sequence table lives in [`keytip_table`]: the match below only walks the prefix.
|
||||
|
||||
use egui::{Context, Event, Key, Modifiers, os::OperatingSystem};
|
||||
|
||||
pub mod keytip_table;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Home,
|
||||
Tab(&'static str),
|
||||
Command(&'static str),
|
||||
Dialog(&'static str),
|
||||
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
|
||||
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
|
||||
Pending(&'static str),
|
||||
/// Route the keytip to the matching ribbon control: open its dropdown or run its click
|
||||
/// handler (which carries the right params), instead of firing the engine command with
|
||||
/// empty params.
|
||||
Control(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -85,28 +96,33 @@ impl KeyTips {
|
||||
} else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) {
|
||||
handled = true;
|
||||
if *key == Key::Escape {
|
||||
if let Some(prefix) = self.prefix.as_mut()
|
||||
&& prefix.pop().is_none()
|
||||
{
|
||||
self.prefix = None;
|
||||
// Escape pops one level; at a single-letter level it
|
||||
// returns to the armed root (empty prefix).
|
||||
if let Some(prefix) = self.prefix.as_mut() {
|
||||
if prefix.is_empty() {
|
||||
self.prefix = None;
|
||||
} else {
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
} else if key.name().len() == 1 {
|
||||
let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name());
|
||||
match next.as_str() {
|
||||
"H" => {
|
||||
self.prefix = Some(next);
|
||||
actions.push(Action::Home);
|
||||
match keytip_table::lookup(&next) {
|
||||
Some(keytip_table::Entry::Tab(tab)) => {
|
||||
// A tab with deeper sequences stays as an active level
|
||||
// (its group badges show); a terminal unit like "JI"
|
||||
// finishes and clears the prefix.
|
||||
self.prefix = keytip_table::has_deeper(&next).then_some(next);
|
||||
actions.push(Action::Tab(tab));
|
||||
}
|
||||
"HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next),
|
||||
"HAR" => self.finish(Action::Command("home.alignRight"), &mut actions),
|
||||
"HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions),
|
||||
"HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions),
|
||||
"HAT" => self.finish(Action::Command("home.alignTop"), &mut actions),
|
||||
"HAM" => self.finish(Action::Command("home.alignMiddle"), &mut actions),
|
||||
"HAB" => self.finish(Action::Command("home.alignBottom"), &mut actions),
|
||||
"HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions),
|
||||
"HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions),
|
||||
_ => {}
|
||||
Some(keytip_table::Entry::Continue) => self.prefix = Some(next),
|
||||
Some(keytip_table::Entry::Finish(action)) => self.finish(action, &mut actions),
|
||||
// No complete sequence, but deeper ones start here: wait for
|
||||
// more input (Excel's two-letter units like Draw's "JI").
|
||||
None if keytip_table::has_deeper(&next) => self.prefix = Some(next),
|
||||
// Unknown letters are consumed while a prefix is active but
|
||||
// leave it unchanged, as Excel does.
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! The keytip sequence table: every Excel-style Alt access sequence and what it does.
|
||||
//!
|
||||
//! The matcher in [`super::KeyTips::process`] and the badge painter in `ribbon.rs` both read
|
||||
//! [`SEQUENCES`], so a sequence is added in exactly one place: `keytips.json`.
|
||||
//!
|
||||
//! `keytips.json` is the human-edited source of truth (grouped, commented, one line per
|
||||
//! sequence; `pending` reserves sequences for commands that do not exist yet). `generated.rs`
|
||||
//! is produced from it by `cargo xtask keytips` — edit the JSON, not the generated file.
|
||||
|
||||
pub(crate) use super::Action;
|
||||
|
||||
mod generated;
|
||||
pub use generated::SEQUENCES;
|
||||
|
||||
/// What a fully-matched sequence does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Entry {
|
||||
/// Switch to this ribbon tab and keep the prefix: the tab's own badges take over.
|
||||
Tab(&'static str),
|
||||
/// Keep the prefix: this sequence is a group-level badge; deeper sequences finish it.
|
||||
Continue,
|
||||
/// Complete the sequence: run the action and clear the prefix.
|
||||
Finish(Action),
|
||||
}
|
||||
|
||||
/// Look up a complete sequence.
|
||||
pub fn lookup(sequence: &str) -> Option<Entry> {
|
||||
SEQUENCES.iter().find(|(seq, _)| *seq == sequence).map(|(_, entry)| *entry)
|
||||
}
|
||||
|
||||
/// True if `prefix` is a strict prefix of any known sequence (badges wait for more keys).
|
||||
pub fn has_deeper(prefix: &str) -> bool {
|
||||
SEQUENCES.iter().any(|(seq, _)| seq.len() > prefix.len() && seq.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// The sequence that switches to `tab_name`, if that tab has one (may be a multi-letter unit
|
||||
/// like Draw's "JI").
|
||||
pub fn tab_sequence(tab_name: &str) -> Option<&'static str> {
|
||||
SEQUENCES.iter().find_map(|(seq, entry)| match entry {
|
||||
Entry::Tab(name) if *name == tab_name => Some(*seq),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// True if `seq` is a group level: an explicit Continue entry, or an implicit level that only
|
||||
/// exists because deeper sequences start with it (like "J" before Draw's "JI").
|
||||
pub fn is_group_level(seq: &str) -> bool {
|
||||
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
|
||||
}
|
||||
|
||||
fn matches_target(action: &Action, target: &str) -> bool {
|
||||
match action {
|
||||
Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target,
|
||||
Action::Dialog(name) => format!("dialog:{name}") == target,
|
||||
Action::Tab(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// All sequences that finish by running `target` (a command id or `dialog:<name>`).
|
||||
pub fn sequences_for(target: &str) -> Vec<&'static str> {
|
||||
SEQUENCES
|
||||
.iter()
|
||||
.filter_map(|(seq, entry)| match entry {
|
||||
Entry::Finish(action) if matches_target(action, target) => Some(*seq),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Group-level letters for a menu anchor: sequences one key past `prefix` that continue toward
|
||||
/// any of `targets` (the menu's items) — the badge shown on the button that opens the menu.
|
||||
pub fn group_extension(prefix: &str, targets: &[&str]) -> Vec<&'static str> {
|
||||
let mut out = Vec::new();
|
||||
for target in targets {
|
||||
for seq in sequences_for(target) {
|
||||
if seq.len() <= prefix.len() + 1 || !seq.starts_with(prefix) {
|
||||
continue;
|
||||
}
|
||||
// The intermediate one key past the prefix is the group badge, if it is a
|
||||
// group-level sequence (explicit or implicit) rather than the finish itself.
|
||||
let candidate = &seq[..prefix.len() + 1];
|
||||
if is_group_level(candidate) && !out.contains(&candidate) {
|
||||
out.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The badge for a menu item: its finish sequence exactly one key below the current prefix.
|
||||
pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> {
|
||||
sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// Badge sequences for a command control: every sequence for `target` that continues from the
|
||||
/// current prefix (the painter strips the prefix, so the remaining letters show).
|
||||
pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> {
|
||||
if prefix.is_empty() {
|
||||
return Vec::new(); // empty prefix shows only tab badges
|
||||
}
|
||||
sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect()
|
||||
}
|
||||
|
||||
/// The badge for a single control: the rest of its finish sequence when it has exactly one, or
|
||||
/// the common group letter when several sequences land on the same control (menu anchors like
|
||||
/// Borders, whose HBA..HBW variants all share "HB").
|
||||
pub fn control_badge(prefix: &str, target: &str) -> Option<&'static str> {
|
||||
if prefix.is_empty() {
|
||||
return None; // empty prefix shows only tab badges
|
||||
}
|
||||
let seqs: Vec<&'static str> = sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect();
|
||||
match seqs.as_slice() {
|
||||
[] => None,
|
||||
[single] => Some(single),
|
||||
many => {
|
||||
let common = |a: &str, b: &str| a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count();
|
||||
let lcp = many.iter().skip(1).fold(many[0].len(), |acc, s| acc.min(common(many[0], s)));
|
||||
(lcp > prefix.len()).then_some(&many[0][..lcp])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single badge for a menu anchor: the direct finish one key past the prefix, or the
|
||||
/// group-level candidate that prefixes the most of `targets`' sequences. Anchors get one
|
||||
/// badge, so overlapping groups (e.g. Pictures' "NP" vs SmartArt's "NM") resolve to the
|
||||
/// dominant one.
|
||||
pub fn anchor_badge(prefix: &str, targets: &[&str]) -> Option<&'static str> {
|
||||
if prefix.is_empty() {
|
||||
return None; // empty prefix shows only tab badges
|
||||
}
|
||||
let all: Vec<&'static str> = targets.iter().flat_map(|t| sequences_for(t)).filter(|s| s.len() > prefix.len() && s.starts_with(prefix)).collect();
|
||||
if all.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let depth = prefix.len() + 1;
|
||||
let mut candidates: Vec<&'static str> = all.iter().filter(|s| s.len() == depth).copied().collect();
|
||||
if candidates.is_empty() {
|
||||
for s in &all {
|
||||
let c = &s[..depth.min(s.len())];
|
||||
if is_group_level(c) && !candidates.contains(&c) {
|
||||
candidates.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut best: Option<(&'static str, usize)> = None;
|
||||
for c in candidates {
|
||||
let score = all.iter().filter(|s| s.starts_with(c)).count();
|
||||
if best.is_none_or(|(_, b)| score > b) {
|
||||
best = Some((c, score));
|
||||
}
|
||||
}
|
||||
best.map(|(c, _)| c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tab_letters_exist_for_standard_tabs() {
|
||||
for (tab, letter) in [
|
||||
("Home", "H"),
|
||||
("Insert", "N"),
|
||||
("Page Layout", "P"),
|
||||
("Formulas", "M"),
|
||||
("Data", "A"),
|
||||
("Review", "R"),
|
||||
("View", "W"),
|
||||
("Draw", "JI"), // two-letter unit, shown as one badge
|
||||
] {
|
||||
assert_eq!(tab_sequence(tab), Some(letter), "tab {tab}");
|
||||
}
|
||||
assert_eq!(tab_sequence("Automate"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequences_for_finds_command_and_dialog_targets() {
|
||||
assert_eq!(sequences_for("home.alignRight"), vec!["HAR"]);
|
||||
assert_eq!(sequences_for("dialog:pasteSpecial"), vec!["HVS", "ES"]);
|
||||
assert_eq!(sequences_for("home.autofitColumnWidth"), vec!["HOI", "OCA"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_levels_wait_for_deeper_sequences() {
|
||||
assert!(has_deeper("J"), "JI is reachable only through the implicit J level");
|
||||
assert!(is_group_level("J"), "implicit levels are group levels");
|
||||
assert!(is_group_level("HV"), "explicit Continue levels are group levels");
|
||||
assert!(!is_group_level("H"), "a terminal tab is not a group level");
|
||||
assert!(!is_group_level("HVS"), "a finish is not a group level");
|
||||
assert!(!has_deeper("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_extension_paints_only_continue_levels() {
|
||||
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
|
||||
assert!(group_extension("", &["dialog:pasteSpecial"]).is_empty(), "tab letters paint on tabs, not buttons");
|
||||
assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_letter_is_one_level_deep() {
|
||||
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
|
||||
assert_eq!(item_letter("HV", "edit.pasteSpecial"), Some("HVP"));
|
||||
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
|
||||
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_badges_require_a_known_prefix() {
|
||||
assert!(control_badges("", "home.alignRight").is_empty(), "empty prefix shows only tab badges");
|
||||
assert_eq!(control_badges("HA", "home.alignRight"), vec!["HAR"]);
|
||||
assert!(control_badges("HZ", "home.alignRight").is_empty(), "unknown prefixes show nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_badge_shows_the_rest_or_the_group_letter() {
|
||||
assert_eq!(control_badge("H", "home.bold"), Some("H1"), "a unique sequence paints its full rest");
|
||||
assert_eq!(control_badge("H", "home.fontColor"), Some("HFC"));
|
||||
assert_eq!(control_badge("H", "home.borders"), Some("HB"), "many variants collapse to the group letter");
|
||||
assert!(control_badge("", "home.bold").is_none(), "empty prefix shows only tab badges");
|
||||
assert!(control_badge("HZ", "home.bold").is_none(), "unknown prefixes show nothing");
|
||||
assert!(control_badge("H", "view.zoom").is_none(), "diverging sequences at the next letter show nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_badge_resolves_to_the_dominant_group() {
|
||||
assert_eq!(anchor_badge("H", &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]), Some("HV"));
|
||||
assert_eq!(anchor_badge("P", &["pageLayout.orientation"]), Some("PO"), "a direct finish paints itself");
|
||||
assert_eq!(anchor_badge("N", &["insert.picture"]), Some("NP"), "NP dominates the lone NM (SmartArt) entry");
|
||||
assert_eq!(anchor_badge("H", &["table.style"]), Some("HT"));
|
||||
assert!(anchor_badge("", &["edit.paste"]).is_none(), "empty prefix shows only tab badges");
|
||||
assert!(anchor_badge("H", &["view.zoom"]).is_none(), "no sequences under this prefix");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.
|
||||
|
||||
use super::{Action, Entry};
|
||||
|
||||
/// All known keytip sequences. Intermediate levels are implicit: the matcher waits
|
||||
/// whenever a pressed letter starts a deeper sequence, so only terminal entries are
|
||||
/// listed here. See keytip_table.rs for the helpers that read this table.
|
||||
pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("H", Entry::Tab("Home")),
|
||||
("N", Entry::Tab("Insert")),
|
||||
("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors.
|
||||
("P", Entry::Tab("Page Layout")),
|
||||
("M", Entry::Tab("Formulas")),
|
||||
("A", Entry::Tab("Data")),
|
||||
("R", Entry::Tab("Review")),
|
||||
("W", Entry::Tab("View")),
|
||||
("E", Entry::Tab("Home")), // Legacy Edit-menu path (Excel 2003); leads to the same Home menus.
|
||||
("O", Entry::Tab("Home")), // Legacy Format-menu path (Excel 2003); leads to the same Home menus.
|
||||
("HAR", Entry::Finish(Action::Command("home.alignRight"))),
|
||||
("HAT", Entry::Finish(Action::Command("home.alignTop"))), // Excel: Excel: Top Align
|
||||
("HAM", Entry::Finish(Action::Command("home.alignMiddle"))), // Excel: Excel: Middle Align
|
||||
("HAB", Entry::Finish(Action::Command("home.alignBottom"))), // Excel: Excel: Bottom Align
|
||||
("HAL", Entry::Finish(Action::Command("home.alignLeft"))),
|
||||
("HAC", Entry::Finish(Action::Command("home.alignCenter"))),
|
||||
("HVS", Entry::Finish(Action::Dialog("pasteSpecial"))),
|
||||
("ES", Entry::Finish(Action::Dialog("pasteSpecial"))), // Excel: Classic Paste Special dialog (legacy Edit route); A/F/T/V are dialog mnemonics
|
||||
("HOI", Entry::Finish(Action::Command("home.autofitColumnWidth"))),
|
||||
("OCA", Entry::Finish(Action::Command("home.autofitColumnWidth"))), // Legacy Format menu.
|
||||
("WVG", Entry::Finish(Action::Command("view.gridlines"))),
|
||||
("H0", Entry::Finish(Action::Command("home.increaseDecimal"))), // Excel: Increase Decimals; containment(Increase Decimal)
|
||||
("H1", Entry::Finish(Action::Command("home.bold"))), // Excel: Bold
|
||||
("H2", Entry::Finish(Action::Command("home.italic"))), // Excel: Italic
|
||||
("H3", Entry::Finish(Action::Command("home.underline"))), // Excel: Excel: Underline; fires immediately (not a menu)
|
||||
("H5", Entry::Finish(Action::Command("home.decreaseIndent"))), // Excel: Decrease Indent
|
||||
("H6", Entry::Finish(Action::Command("home.increaseIndent"))), // Excel: Increase Indent
|
||||
("H9", Entry::Finish(Action::Command("home.decreaseDecimal"))), // Excel: Decrease Decimals; containment(Decrease Decimal)
|
||||
("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins)
|
||||
("HB", Entry::Continue), // Excel: Borders Menu; group level:
|
||||
("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders)
|
||||
("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD
|
||||
("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD
|
||||
("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD
|
||||
("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD
|
||||
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
|
||||
("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD
|
||||
("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD)
|
||||
("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD
|
||||
("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders)
|
||||
("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD
|
||||
("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD
|
||||
("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD
|
||||
("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD
|
||||
("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders)
|
||||
("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD
|
||||
("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD
|
||||
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
|
||||
("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD)
|
||||
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
|
||||
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
|
||||
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
|
||||
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
|
||||
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
|
||||
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
|
||||
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
|
||||
("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks
|
||||
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
|
||||
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
|
||||
("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting)
|
||||
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
|
||||
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
|
||||
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
|
||||
("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands
|
||||
("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane
|
||||
("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace
|
||||
("HFDS", Entry::Finish(Action::Dialog("goToSpecial"))), // Excel: Go To Special
|
||||
("HFF", Entry::Finish(Action::Control("home.fontName"))), // Excel: Font Selection; opens the font dropdown
|
||||
("HFG", Entry::Finish(Action::Command("home.increaseFontSize"))), // Excel: Increase Font Size
|
||||
("HFI", Entry::Continue), // Excel: Fill; group level: containment(Fill Up)
|
||||
("HFIA", Entry::Finish(Action::Pending("home.fillAcrossWorksheets"))), // pending; Excel: Fill Across Worksheets; Fill Across Worksheets; reserved until the command lands
|
||||
("HFID", Entry::Finish(Action::Command("edit.fillDown"))), // Excel: Fill Down
|
||||
("HFIF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill
|
||||
("HFIJ", Entry::Finish(Action::Command("home.justify"))), // Excel: Fill Justify; containment(Justify)
|
||||
("HFIL", Entry::Finish(Action::Command("edit.fillLeft"))), // Excel: Fill Left
|
||||
("HFIR", Entry::Finish(Action::Command("edit.fillRight"))), // Excel: Fill Right
|
||||
("HFIS", Entry::Finish(Action::Dialog("series"))), // Excel: Fill Series; containment(Series)
|
||||
("HFIU", Entry::Finish(Action::Command("edit.fillUp"))), // Excel: Fill Up
|
||||
("HFK", Entry::Finish(Action::Command("home.decreaseFontSize"))), // Excel: Decrease Font Size
|
||||
("HFM", Entry::Finish(Action::Dialog("formatCells"))), // Excel: More Number Formats; More Number Formats opens Format Cells
|
||||
("HFP", Entry::Finish(Action::Command("edit.formatPainter"))), // Excel: Format Painter
|
||||
("HFQ", Entry::Finish(Action::Control("home.orientation"))), // Excel: Orientation Menu; group level: containment(Orientation)
|
||||
("HFS", Entry::Finish(Action::Control("home.fontSize"))), // Excel: Font Size; opens the font-size dropdown
|
||||
("HH", Entry::Finish(Action::Control("home.fillColor"))), // Excel: Fill Color; opens the fill-color palette
|
||||
("HIC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert Columns; Insert Columns
|
||||
("HIE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert Copied Cells; Insert Cells (copied cells TBD)
|
||||
("HII", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert Cells
|
||||
("HIR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert Rows; Insert Rows
|
||||
("HIS", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert Sheet
|
||||
("HJ", Entry::Continue), // Excel: Cell Styles; group level:
|
||||
("HJN", Entry::Finish(Action::Command("home.newCellStyle"))), // Excel: New Cell Style
|
||||
("HK", Entry::Finish(Action::Command("home.comma"))), // Excel: Comma Style
|
||||
("HL", Entry::Continue), // Excel: Conditional Formatting; group level: containment(Conditional Formatting)
|
||||
("HLC", Entry::Continue), // Excel: Clear Rules Menu; group level: containment(Clear Rules)
|
||||
("HLCE", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Sheet; containment(Clear Rules)
|
||||
("HLCS", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Cells; containment(Clear Rules)
|
||||
("HLCT", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Table; containment(Clear Rules)
|
||||
("HLD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Data Bars; Data Bars (submenu TBD)
|
||||
("HLH", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Highlight Cells; Highlight Cells (submenu TBD)
|
||||
("HLN", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: New Rule; New Rule (submenu TBD)
|
||||
("HLR", Entry::Finish(Action::Command("home.manageRules"))), // Excel: Manage Rules; containment(Manage Rules)
|
||||
("HLT", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Top/Bottom Rules; Top/Bottom (submenu TBD)
|
||||
("HM", Entry::Continue), // Excel: Merge & Center Menu; group level: containment(Merge & Center)
|
||||
("HMA", Entry::Finish(Action::Command("home.mergeAcross"))), // Excel: Merge Across
|
||||
("HMC", Entry::Finish(Action::Command("home.mergeCenter"))), // Excel: Merge & Center
|
||||
("HMM", Entry::Finish(Action::Command("home.mergeCells"))), // Excel: Merge Cells
|
||||
("HMU", Entry::Finish(Action::Command("home.unmergeCells"))), // Excel: Unmerge Cells
|
||||
("HN", Entry::Finish(Action::Control("home.numberFormat"))), // Excel: Number Format; opens the number-format dropdown
|
||||
("HOA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: AutoFit Row Height
|
||||
("HOD", Entry::Finish(Action::Dialog("defaultWidth"))), // Excel: Default Column Width; containment(Column Width)
|
||||
("HOE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format Cells
|
||||
("HOH", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Row Height
|
||||
("HOL", Entry::Finish(Action::Command("home.lockCell"))), // Excel: Lock Cell; containment(Lock Cell)
|
||||
("HOM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Move or Copy Sheet
|
||||
("HOP", Entry::Finish(Action::Dialog("protectSheet"))), // Excel: Protect Sheet
|
||||
("HOR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Rename Sheet
|
||||
("HOT", Entry::Finish(Action::Command("sheet.tabColor"))), // Excel: Tab Color
|
||||
("HOUC", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Hide Columns
|
||||
("HOUH", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Unhide Sheet
|
||||
("HOUL", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Unhide Columns
|
||||
("HOUO", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Unhide Rows
|
||||
("HOUR", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Hide Rows
|
||||
("HOUS", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Hide Sheet
|
||||
("HOW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Column Width
|
||||
("HP", Entry::Finish(Action::Command("home.percent"))), // Excel: Excel: Percentage Style
|
||||
("HSC", Entry::Finish(Action::Command("data.clearFilter"))), // Excel: Clear Filter
|
||||
("HSF", Entry::Finish(Action::Command("data.filter"))), // Excel: Filter
|
||||
("HSO", Entry::Finish(Action::Command("data.sortDescending"))), // Excel: Sort Z to A
|
||||
("HSS", Entry::Finish(Action::Command("data.sortAscending"))), // Excel: Sort A to Z
|
||||
("HSU", Entry::Finish(Action::Command("data.sort"))), // Excel: Custom Sort
|
||||
("HT", Entry::Continue), // Excel: Format as Table; group level:
|
||||
("HTN", Entry::Finish(Action::Command("table.style"))), // Excel: New Table Style
|
||||
("HTP", Entry::Finish(Action::Command("table.style"))), // Excel: New Pivot Table Style
|
||||
("HU", Entry::Continue), // Excel: AutoSum; group level:
|
||||
("HUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Average; AutoSum: Average (variant TBD)
|
||||
("HUC", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Count Numbers; AutoSum: Count (variant TBD)
|
||||
("HUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Min; AutoSum: Min (variant TBD)
|
||||
("HUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Max; AutoSum: Max (variant TBD)
|
||||
("HV", Entry::Continue), // Excel: Paste; group level:
|
||||
("HVP", Entry::Finish(Action::Command("edit.pasteSpecial"))), // Excel: Paste
|
||||
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
|
||||
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
|
||||
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
|
||||
("NC1", Entry::Finish(Action::Control("insert.chart.column"))), // Excel: Column Chart; Column chart (kind TBD)
|
||||
("NC2", Entry::Finish(Action::Command("review.newComment"))), // Excel: New Comment
|
||||
("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern)
|
||||
("NCB", Entry::Finish(Action::Command("insert.checkbox"))), // Excel: Checkbox
|
||||
("ND", Entry::Finish(Action::Control("insert.chart.scatter"))), // Excel: Scatter Chart; Scatter (kind TBD)
|
||||
("NE", Entry::Finish(Action::Pending("insert.equation"))), // pending; Excel: Equations; Equations; reserved until the command lands
|
||||
("NF", Entry::Continue), // Excel: Online Pictures; group level: containment(Picture)
|
||||
("NFP", Entry::Continue), // Excel: From Picture Menu; group level: containment(Picture)
|
||||
("NFPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture From File; containment(Picture)
|
||||
("NH1", Entry::Finish(Action::Dialog("headerFooter"))), // Excel: Header & Footer
|
||||
("NHF", Entry::Finish(Action::Command("pageLayout.headerFooter"))), // Excel: Header & Footer
|
||||
("NHI", Entry::Finish(Action::Control("insert.chart.hierarchy"))), // Excel: Hierarchy Chart; Hierarchy (kind TBD)
|
||||
("NI1", Entry::Finish(Action::Command("insert.chart"))), // Excel: Waterfall Chart; Waterfall (kind TBD)
|
||||
("NI2", Entry::Finish(Action::Dialog("insertLink"))), // Excel: Link
|
||||
("NJ", Entry::Finish(Action::Pending("insert.object"))), // pending; Excel: Object; insert.object missing; reserved until the command lands
|
||||
("NM1T", Entry::Finish(Action::Command("insert.picture"))), // Excel: SmartArt Picture; containment(Picture)
|
||||
("NM2", Entry::Finish(Action::Command("insert.chart"))), // Excel: Map Chart; Map (kind TBD)
|
||||
("NN1", Entry::Finish(Action::Control("insert.chart.line"))), // Excel: Line Chart; Line chart (kind TBD)
|
||||
("NNS", Entry::Finish(Action::Command("insert.icons"))), // Excel: Icons
|
||||
("NP", Entry::Continue), // Excel: Picture; group level:
|
||||
("NPB", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from File; containment(Picture)
|
||||
("NPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place in Cell; Picture in cell (source TBD)
|
||||
("NPI", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place in Cell Menu; Picture in cell (source TBD)
|
||||
("NPO", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place over Cells Menu; Picture over cells (source TBD)
|
||||
("NQ", Entry::Finish(Action::Control("insert.chart.pie"))), // Excel: Pie Chart; Pie (kind TBD)
|
||||
("NR", Entry::Finish(Action::Command("insert.recommendedCharts"))), // Excel: Recommended Charts
|
||||
("NSA", Entry::Finish(Action::Control("insert.chart.statistic"))), // Excel: Statistical Chart; Statistical chart (kind TBD)
|
||||
("NSC", Entry::Finish(Action::Pending("insert.screenshot"))), // pending; Excel: Screenshot Menu; Screenshot; reserved until the command lands
|
||||
("NSD", Entry::Finish(Action::Command("insert.chart"))), // Excel: Combo Chart; Combo (kind TBD)
|
||||
("NSF", Entry::Finish(Action::Command("insert.slicer"))), // Excel: Slicer; insert.slicer missing (gap #8); reserved until the command lands
|
||||
("NSH", Entry::Finish(Action::Command("insert.shape"))), // Excel: Shapes
|
||||
("NSP", Entry::Finish(Action::Command("insert.recommendedPivotTables"))), // Excel: Recommended PivotTables
|
||||
("NST", Entry::Finish(Action::Pending("insert.timeline"))), // pending; Excel: Timeline; insert.timeline missing (gap #8); reserved until the command lands
|
||||
("NSZ", Entry::Finish(Action::Command("insert.pivotChart"))), // Excel: PivotChart; PivotChart; reserved until the command lands
|
||||
("NT", Entry::Finish(Action::Command("insert.table"))), // Excel: Table
|
||||
("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol)
|
||||
("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern)
|
||||
("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable
|
||||
("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands
|
||||
("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
|
||||
("NZK", Entry::Continue), // Excel: Sparklines Menu; group level:
|
||||
("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline
|
||||
("NZKSO", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Column Sparkline
|
||||
("NZKSW", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Win/Loss Sparkline
|
||||
("NZTX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
|
||||
("NZT", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Excel: Text Box (modern)
|
||||
("PAE", Entry::Finish(Action::Command("arrange.sendBackward"))), // Excel: Send Backward
|
||||
("PAF", Entry::Finish(Action::Command("arrange.bringForward"))), // Excel: Bring Forward
|
||||
("PB", Entry::Continue), // Excel: Breaks; group level:
|
||||
("PBI", Entry::Finish(Action::Command("pageLayout.breaks"))), // Excel: Insert Page Break; Insert Page Break
|
||||
("PBR", Entry::Finish(Action::Command("pageLayout.breaks"))), // Excel: Remove Page Break; Remove Page Break
|
||||
("PGB", Entry::Finish(Action::Command("pageLayout.background"))), // Excel: Background; Excel badge GB (Background in the Page Setup group)
|
||||
("PI", Entry::Finish(Action::Command("pageLayout.printTitles"))), // Excel: Print Titles
|
||||
("PM", Entry::Continue), // Excel: Margins; group level:
|
||||
("PMA", Entry::Finish(Action::Command("pageLayout.margins"))), // Excel: Custom Margins; containment(Margins)
|
||||
("PO", Entry::Finish(Action::Command("pageLayout.orientation"))), // Excel: Orientation
|
||||
("PPG", Entry::Finish(Action::Command("pageLayout.printGridlines"))), // Excel: Print Gridlines
|
||||
("PPH", Entry::Finish(Action::Command("pageLayout.printHeadings"))), // Excel: Print Headings
|
||||
("PR", Entry::Continue), // Excel: Print Area; group level:
|
||||
("PRS", Entry::Finish(Action::Command("pageLayout.printArea"))), // Excel: Set Print Area
|
||||
("PSP", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: Page Setup
|
||||
("PSZ", Entry::Finish(Action::Command("pageLayout.size"))), // Excel: Size
|
||||
("PTC", Entry::Finish(Action::Command("pageLayout.themeColors"))), // Excel: Theme Colors; containment(Colors)
|
||||
("PTE", Entry::Finish(Action::Command("pageLayout.themeEffects"))), // Excel: Theme Effects; containment(Effects)
|
||||
("PTF", Entry::Finish(Action::Command("pageLayout.themeFonts"))), // Excel: Theme Fonts; containment(Fonts)
|
||||
("PTH", Entry::Continue), // Excel: Themes; group level:
|
||||
("PTHB", Entry::Finish(Action::Command("pageLayout.theme"))), // Excel: Browse Themes; containment(Themes)
|
||||
("PVG", Entry::Finish(Action::Command("view.gridlines"))), // Excel: View Gridlines
|
||||
("PVH", Entry::Finish(Action::Command("view.headings"))), // Excel: View Headings
|
||||
("MA", Entry::Continue), // Excel: Remove Arrows Menu; group level: containment(Remove Arrows)
|
||||
("MAA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Remove All Arrows; landed in the catalog; live since T7 probe
|
||||
("MAP", Entry::Finish(Action::Pending("formulas.removePrecedentArrows"))), // pending; Excel: Remove Precedent Arrows; Remove Precedent Arrows; reserved until the command lands
|
||||
("MB", Entry::Finish(Action::Command("formulas.calculateNow"))), // Excel: Calculate Now
|
||||
("MC", Entry::Finish(Action::Command("formulas.createFromSelection"))), // Excel: Create From Selection
|
||||
("MD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Trace Dependents
|
||||
("ME", Entry::Finish(Action::Control("formulas.dateTime"))), // Excel: Date & Time Menu; group level: containment(Date & Time)
|
||||
("MF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert Function
|
||||
("MG", Entry::Finish(Action::Control("formulas.mathTrig"))), // Excel: Math & Trig Menu; group level: containment(Math & Trig)
|
||||
("MH", Entry::Finish(Action::Command("formulas.showFormulas"))), // Excel: Show Formulas
|
||||
("MI", Entry::Continue), // Excel: Financial Menu; group level: containment(Financial)
|
||||
("MI1", Entry::Finish(Action::Control("formulas.financial"))), // Excel: Excel: Financial; first item of the dropdown
|
||||
("MJ", Entry::Finish(Action::Command("formulas.calculateSheet"))), // Excel: Calculate Sheet
|
||||
("MK", Entry::Continue), // Excel: Error Checking Menu; group level: containment(Error Checking)
|
||||
("MKK", Entry::Finish(Action::Dialog("errorChecking"))), // Excel: Error Checking
|
||||
("ML", Entry::Finish(Action::Control("formulas.logical"))), // Excel: Logical Menu; group level: containment(Logical)
|
||||
("MM", Entry::Finish(Action::Dialog("defineName"))), // Excel: Define Name
|
||||
("MN", Entry::Finish(Action::Dialog("nameManager"))), // Excel: Name Manager
|
||||
("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference)
|
||||
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
|
||||
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
|
||||
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
|
||||
("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands
|
||||
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
|
||||
("MU", Entry::Continue), // Excel: AutoSum Menu; group level:
|
||||
("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD)
|
||||
("MUC", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Count Numbers; AutoSum: Count (variant TBD)
|
||||
("MUF", Entry::Finish(Action::Command("formulas.moreFunctions"))), // Excel: More Functions (AutoSum); containment(More Functions)
|
||||
("MUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Min; AutoSum: Min (variant TBD)
|
||||
("MUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Max; AutoSum: Max (variant TBD)
|
||||
("MUS", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Sum
|
||||
("MV", Entry::Finish(Action::Dialog("evaluateFormula"))), // Excel: Evaluate Formula
|
||||
("MW", Entry::Finish(Action::Command("formulas.watchWindow"))), // Excel: Watch Window
|
||||
("MX", Entry::Continue), // Excel: Calculation Options Menu; group level: containment(Calculation Options)
|
||||
("MXA", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Automatic Calculation; Calculation options: Automatic
|
||||
("MXM", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Manual Calculation; Calculation options: Manual
|
||||
("MXP", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Partial Calculation; Calculation options: Partial
|
||||
("AB", Entry::Finish(Action::Dialog("subtotal"))), // Excel: Subtotal
|
||||
("AC", Entry::Finish(Action::Command("data.clearFilter"))), // Excel: Clear Filter
|
||||
("AD", Entry::Finish(Action::Command("data.consolidate"))), // Excel: Consolidate
|
||||
("AE", Entry::Finish(Action::Dialog("textToColumns"))), // Excel: Text to Columns
|
||||
("AF1", Entry::Continue), // Excel: Data from Picture Menu; group level: feature gap; reserved until the command lands
|
||||
("AF1C", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from Clipboard (source TBD)
|
||||
("AF1P", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from File (source TBD)
|
||||
("AFA", Entry::Finish(Action::Pending("data.fromAccess"))), // pending; Excel: Get External Data: From Access; gap #12 Get & Transform; reserved until the command lands
|
||||
("AFC", Entry::Finish(Action::Command("data.forecastSheet"))), // Excel: Forecast Sheet; IN FLIGHT: vangel76 PR #325; reserved until the command lands
|
||||
("AFE", Entry::Finish(Action::Pending("data.analyzeData"))), // pending; Excel: Analyze Data; AI feature GAP; reserved until the command lands
|
||||
("AFF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill
|
||||
("AFO", Entry::Finish(Action::Pending("data.fromOtherSources"))), // pending; Excel: Get External Data: From Other Sources; gap #12; reserved until the command lands
|
||||
("AFT", Entry::Finish(Action::Pending("data.fromText"))), // pending; Excel: Get External Data: From Text; gap #12; reserved until the command lands
|
||||
("AFW", Entry::Finish(Action::Pending("data.fromWeb"))), // pending; Excel: Get External Data: From Web; reserved until the command lands
|
||||
("AG", Entry::Continue), // Excel: Group Menu; group level: containment(Group)
|
||||
("AGA", Entry::Finish(Action::Pending("data.autoOutline"))), // pending; Excel: Auto Outline; Auto Outline; reserved until the command lands
|
||||
("AGG", Entry::Finish(Action::Command("data.group"))), // Excel: Group
|
||||
("AH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Hide Detail
|
||||
("AJ", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Show Detail
|
||||
("AK", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Edit Links; Edit Links (Queries & Connections);
|
||||
("AM", Entry::Finish(Action::Dialog("removeDuplicates"))), // Excel: Remove Duplicates
|
||||
("AO", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Queries & Connections
|
||||
("AP1", Entry::Finish(Action::Command("file.properties"))), // Excel: Properties
|
||||
("APN", Entry::Continue), // Excel: Get Data (Power Query) Menu; group level: Power Query variants = GAP
|
||||
("APND", Entry::Finish(Action::Pending("data.fromDatabase"))), // pending; Excel: From Database (Microsoft Query); gap #12; reserved until the command lands
|
||||
("APNE", Entry::Finish(Action::Pending("data.powerQueryEditor"))), // pending; Excel: Launch Power Query Editor; gap #12; reserved until the command lands
|
||||
("APNG", Entry::Finish(Action::Command("data.getData"))), // Excel: Get Data (Power Query); containment(Get Data)
|
||||
("APNH", Entry::Finish(Action::Pending("data.fromHtml"))), // pending; Excel: From HTML; gap #12; reserved until the command lands
|
||||
("APNL", Entry::Finish(Action::Pending("data.fromSqlServer"))), // pending; Excel: From SQL Server ODBC; gap #12; reserved until the command lands
|
||||
("APNS", Entry::Finish(Action::Pending("data.dataSourceSettings"))), // pending; Excel: Data Source Settings; gap #12; reserved until the command lands
|
||||
("APNT", Entry::Finish(Action::Pending("data.fromTextLegacy"))), // pending; Excel: From Text (Legacy); gap #12; reserved until the command lands
|
||||
("APR", Entry::Finish(Action::Pending("data.recentSources"))), // pending; Excel: Recent Sources; gap #12; reserved until the command lands
|
||||
("APS", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Show Queries
|
||||
("APT", Entry::Finish(Action::Pending("data.fromTable"))), // pending; Excel: From Table (query); gap #12; reserved until the command lands
|
||||
("AQ", Entry::Finish(Action::Command("data.advancedFilter"))), // Excel: Advanced Filter
|
||||
("AR", Entry::Continue), // group: group: Queries & Connections
|
||||
("ARA", Entry::Finish(Action::Command("data.refreshAll"))), // Excel: Refresh All
|
||||
("ARC", Entry::Finish(Action::Pending("data.cancelRefresh"))), // pending; Excel: Cancel Refresh; gap #12; reserved until the command lands
|
||||
("ARO", Entry::Finish(Action::Pending("data.connectionProperties"))), // pending; Excel: Connection Properties; gap #12; reserved until the command lands
|
||||
("ARR", Entry::Finish(Action::Command("pivot.refresh"))), // Excel: Refresh
|
||||
("ARS", Entry::Finish(Action::Pending("data.refreshStatus"))), // pending; Excel: Refresh Status; gap #12; reserved until the command lands
|
||||
("ASA", Entry::Finish(Action::Command("data.sortAscending"))), // Excel: Sort A to Z
|
||||
("ASD", Entry::Finish(Action::Command("data.sortDescending"))), // Excel: Sort Z to A
|
||||
("ASS", Entry::Finish(Action::Dialog("sort"))), // Excel: Sort
|
||||
("AT", Entry::Finish(Action::Command("data.filter"))), // Excel: Filter
|
||||
("AU", Entry::Continue), // Excel: Ungroup Menu; group level: containment(Ungroup)
|
||||
("AUC", Entry::Finish(Action::Pending("data.clearOutline"))), // pending; Excel: Clear Outline; feature gap; reserved until the command lands
|
||||
("AUU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Ungroup
|
||||
("AV", Entry::Continue), // Excel: Data Validation Menu; group level: containment(Data Validation)
|
||||
("AVI", Entry::Finish(Action::Command("data.circleInvalid"))), // Excel: Circle Invalid Data
|
||||
("AVR", Entry::Finish(Action::Pending("data.clearValidationCircles"))), // pending; Excel: Clear Validation Circles; reserved until the command lands
|
||||
("AVV", Entry::Finish(Action::Command("data.validation"))), // Excel: Data Validation
|
||||
("AW", Entry::Continue), // group: group: What-If (goalSeek/scenarioManager/dataTable)
|
||||
("AWG", Entry::Finish(Action::Dialog("goalSeek"))), // Excel: Goal Seek
|
||||
("AWS", Entry::Finish(Action::Command("data.scenarioManager"))), // Excel: Scenario Manager
|
||||
("AWT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data Table
|
||||
("AX", Entry::Finish(Action::Pending("data.existingConnections"))), // pending; Excel: Existing Connections; gap #12; reserved until the command lands
|
||||
("AY", Entry::Finish(Action::Command("data.reapply"))), // Excel: Reapply Filter; containment(Reapply)
|
||||
("RA1", Entry::Finish(Action::Dialog("accessibility"))), // Excel: Check Accessibility
|
||||
("RA2", Entry::Finish(Action::Command("review.showComments"))), // Excel: Show All Comments; Show All Comments; reserved until the command lands
|
||||
("RC", Entry::Finish(Action::Dialog("comment"))), // Excel: New Comment
|
||||
("RD", Entry::Finish(Action::Command("review.deleteComment"))), // Excel: Delete Comment
|
||||
("RG", Entry::Finish(Action::Pending("review.showChanges"))), // pending; Excel: Track Changes; reserved until the command lands
|
||||
("RL", Entry::Finish(Action::Pending("review.translate"))), // pending; Excel: Translate; feature gap; reserved until the command lands
|
||||
("RN", Entry::Finish(Action::Command("review.nextComment"))), // Excel: Next Comment
|
||||
("RO", Entry::Finish(Action::Command("review.protectWorkbook"))), // Excel: Protect and Share Workbook
|
||||
("RPS", Entry::Finish(Action::Dialog("protectSheet"))), // Excel: Protect Sheet
|
||||
("RPW", Entry::Finish(Action::Command("review.protectWorkbook"))), // Excel: Protect Workbook
|
||||
("RRS", Entry::Finish(Action::Pending("review.smartLookup"))), // pending; Excel: Smart Lookup; feature gap; reserved until the command lands
|
||||
("RS", Entry::Finish(Action::Dialog("spelling"))), // Excel: Spelling
|
||||
("RTN", Entry::Finish(Action::Command("review.newNote"))), // Excel: New Note
|
||||
("RV", Entry::Finish(Action::Command("review.previousComment"))), // Excel: Previous Comment
|
||||
("WA", Entry::Finish(Action::Command("view.arrangeAll"))), // Excel: Arrange All
|
||||
("WC", Entry::Finish(Action::Command("view.customViews"))), // Excel: Custom Views
|
||||
("WF", Entry::Continue), // Excel: Freeze Panes Menu; group level:
|
||||
("WFC", Entry::Finish(Action::Command("view.freezeFirstColumn"))), // Excel: Freeze First Column
|
||||
("WFF", Entry::Finish(Action::Command("view.freezePanes"))), // Excel: Freeze Panes
|
||||
("WFR", Entry::Finish(Action::Command("view.freezeTopRow"))), // Excel: Freeze Top Row
|
||||
("WG", Entry::Finish(Action::Command("view.zoomToSelection"))), // Excel: Zoom To Selection
|
||||
("WH", Entry::Finish(Action::Command("view.hideWindow"))), // Excel: Hide Window
|
||||
("WI", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: Page Break Preview
|
||||
("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param)
|
||||
("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal)
|
||||
("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros)
|
||||
("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu)
|
||||
("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros)
|
||||
("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window
|
||||
("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View
|
||||
("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog
|
||||
("WR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler
|
||||
("WS", Entry::Finish(Action::Command("view.split"))), // Excel: Split
|
||||
("WT", Entry::Finish(Action::Pending("view.resetWindowPosition"))), // pending; Excel: Reset Window Position; reserved until the command lands
|
||||
("WU", Entry::Finish(Action::Command("view.unhideWindow"))), // Excel: Unhide Window
|
||||
("WVC", Entry::Finish(Action::Pending("view.focusCell"))), // pending; Excel: Focus Cell Menu; Focus Cell; reserved until the command lands
|
||||
("WVF", Entry::Finish(Action::Control("view.formulaBar"))), // Excel: Toggle Formula Bar; toggles the formula bar
|
||||
("WVH", Entry::Finish(Action::Command("view.headings"))), // Excel: Toggle Headings; containment(Headings)
|
||||
("WVN", Entry::Finish(Action::Pending("view.navigation"))), // pending; Excel: Navigation; Navigation pane; reserved until the command lands
|
||||
("WVR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler
|
||||
("WW", Entry::Finish(Action::Command("view.switchWindows"))), // Excel: Switch Windows
|
||||
("JAM", Entry::Finish(Action::Command("chart.formatSelection"))), // Excel: Chart format: Format Selection pane; containment(Format Selection)
|
||||
("JCA", Entry::Finish(Action::Command("chart.addElement"))), // Excel: Chart: Add Chart Element; containment(Add Chart Element)
|
||||
("JCC", Entry::Finish(Action::Command("chart.changeType"))), // Excel: Chart: Change Chart Type; containment(Change Chart Type)
|
||||
("JTS", Entry::Finish(Action::Command("table.style"))), // Excel: Table: table style
|
||||
("JTV", Entry::Finish(Action::Command("table.summarizePivot"))), // Excel: Table: Summarize with PivotTable; containment(Summarize with PivotTable)
|
||||
("FA", Entry::Finish(Action::Command("file.saveAs"))), // Excel: Save / Save As page; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("FC", Entry::Finish(Action::Command("file.close"))), // Excel: Close workbook
|
||||
("FEA", Entry::Finish(Action::Command("file.exportPdf"))), // Excel: Export: create PDF/XPS; Export: create PDF/XPS
|
||||
("FEC", Entry::Finish(Action::Command("file.saveAs"))), // Excel: Export: change file type; Export: change file type
|
||||
("FI", Entry::Finish(Action::Command("file.properties"))), // Excel: Info page; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page
|
||||
("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print)
|
||||
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
+103
-33
@@ -26,6 +26,7 @@ pub mod outline;
|
||||
pub mod panes;
|
||||
pub mod paper_view;
|
||||
pub mod pivot_pane;
|
||||
pub mod printing;
|
||||
pub mod ribbon;
|
||||
pub mod selection_glide;
|
||||
pub mod tabs;
|
||||
@@ -61,6 +62,10 @@ pub struct UiState {
|
||||
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
|
||||
pub animations: bool,
|
||||
pub recent: Vec<String>,
|
||||
/// One-shot keytip request for a ribbon control: set when a keytip action resolves to
|
||||
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
|
||||
#[serde(skip)]
|
||||
pub keytip_invoke: Option<&'static str>,
|
||||
/// Interface language. English by default (so tests and headless renders never depend on the
|
||||
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
|
||||
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
|
||||
@@ -104,6 +109,7 @@ impl Default for UiState {
|
||||
status_bar: true,
|
||||
animations: true,
|
||||
recent: vec![],
|
||||
keytip_invoke: None,
|
||||
language: i18n::Language::En,
|
||||
interface_scale: 100,
|
||||
}
|
||||
@@ -178,6 +184,10 @@ pub struct Notice {
|
||||
/// Host services the platform shell provides (file pickers, opening URLs).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Desktop: discover configured printers on a worker.
|
||||
pub printers: Option<printing::Discover>,
|
||||
/// Desktop: submit the PDF with the chosen settings on a worker.
|
||||
pub print_native: Option<printing::Submit>,
|
||||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Native picture picker (PNG/JPEG), separate from workbook opening.
|
||||
@@ -229,6 +239,8 @@ pub struct SheetApp {
|
||||
/// Dismissible info bar under the ribbon (open-time notices).
|
||||
pub notice: Option<Notice>,
|
||||
pub services: Services,
|
||||
pub printing: printing::State,
|
||||
print_feedback_seen: Option<printing::Feedback>,
|
||||
/// Synthetic input from agents, one batch per frame (drags need several frames).
|
||||
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
|
||||
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
|
||||
@@ -275,6 +287,8 @@ impl SheetApp {
|
||||
toast: None,
|
||||
notice: None,
|
||||
services,
|
||||
printing: Default::default(),
|
||||
print_feedback_seen: None,
|
||||
synthetic: Default::default(),
|
||||
control_rx: None,
|
||||
grid: grid::GridState {
|
||||
@@ -423,8 +437,12 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
pub fn run_or_alert(&mut self, id: &str, params: Json) {
|
||||
// Printing from the UI also hands the PDF to the host's print flow (web).
|
||||
// A menu/shortcut opens the settings first; the Print page's button submits explicitly.
|
||||
if id == "file.print" {
|
||||
if self.services.print_native.is_some() {
|
||||
self.open_backstage(backstage::Page::Print);
|
||||
return;
|
||||
}
|
||||
return self.print_now(params);
|
||||
}
|
||||
if let Err(e) = self.run(id, params) {
|
||||
@@ -441,29 +459,39 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web
|
||||
/// build) hands the bytes straight to the browser's print flow; errors show the usual
|
||||
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
|
||||
/// (which still produces the PDF — just without a UI to act on the result yet).
|
||||
/// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the
|
||||
/// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible
|
||||
/// on the Print page. The engine command still returns PDF bytes without submitting.
|
||||
pub fn print_now(&mut self, params: Json) {
|
||||
match self.run("file.print", params) {
|
||||
Ok(r) => {
|
||||
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
|
||||
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
|
||||
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
|
||||
return;
|
||||
};
|
||||
if let Some(print) = &self.services.print {
|
||||
print(&bytes);
|
||||
}
|
||||
}
|
||||
Err(e) => self.alert(e),
|
||||
if let Err(error) = self.submit_print(params) {
|
||||
self.alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_print(&mut self, params: Json) -> Result<Json, String> {
|
||||
if self.services.print_native.is_some() && !self.printing.ready() {
|
||||
return Err("Select an available printer in File > Print before submitting.".into());
|
||||
}
|
||||
let pdf = self.run("file.print", params)?;
|
||||
let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?;
|
||||
let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?;
|
||||
let len = bytes.len();
|
||||
if let Some(submit) = &self.services.print_native {
|
||||
self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?;
|
||||
self.print_feedback_seen = None;
|
||||
} else if let Some(print) = &self.services.print {
|
||||
print(&bytes);
|
||||
} else {
|
||||
return Err("Printing is unavailable here; export a PDF instead.".into());
|
||||
}
|
||||
Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()}))
|
||||
}
|
||||
|
||||
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
|
||||
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
|
||||
let r = match id {
|
||||
"ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
|
||||
"ui.printSubmit" => self.submit_print(p.clone()),
|
||||
"ui.ribbonTab" => {
|
||||
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
|
||||
Ok(Json::Null)
|
||||
@@ -644,17 +672,27 @@ impl SheetApp {
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
match self.run("file.open", json!({"path": path})) {
|
||||
Ok(r) => {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(20);
|
||||
self.show_open_warnings(&r);
|
||||
}
|
||||
Ok(r) => self.after_open(Some(path), &r),
|
||||
// A password-protected workbook: ask for the password; the dialog opens it.
|
||||
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})),
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
|
||||
/// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent
|
||||
/// list (for a file on disk) and the open warnings notice. Shared by File › Open, the
|
||||
/// password dialog and the control channel.
|
||||
pub fn after_open(&mut self, path: Option<&str>, r: &Json) {
|
||||
self.backstage = None;
|
||||
if let Some(path) = path {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(20);
|
||||
}
|
||||
self.show_open_warnings(r);
|
||||
}
|
||||
|
||||
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
|
||||
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
|
||||
/// Kept deliberately generic: any open-time warning can reach the user this way.
|
||||
@@ -839,8 +877,31 @@ impl SheetApp {
|
||||
self.grid.ensure_visible = true;
|
||||
}
|
||||
|
||||
/// Terminal feedback also reaches the normal window when printing starts through control.
|
||||
pub fn poll_printing(&mut self, ctx: &egui::Context) {
|
||||
self.printing.poll(ctx);
|
||||
if self.printing.message == self.print_feedback_seen {
|
||||
return;
|
||||
}
|
||||
self.print_feedback_seen = self.printing.message.clone();
|
||||
let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) =
|
||||
self.printing.message.clone()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned());
|
||||
if matches!(message, printing::Feedback::Submitted(_)) {
|
||||
self.toast = Some((text, now_ms()));
|
||||
} else {
|
||||
self.alert(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame logic (control channel, screenshots). Call before `ui`.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
|
||||
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
|
||||
self.ui.keytip_invoke = None;
|
||||
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
|
||||
self.fonts_ready = false;
|
||||
self.fonts_set = false;
|
||||
@@ -917,12 +978,16 @@ impl SheetApp {
|
||||
.unwrap_or_default();
|
||||
for (name, bytes) in arrived {
|
||||
let b64 = gridcraft_engine::io::base64_encode(&bytes);
|
||||
match self.run("file.open", json!({"name": name, "base64": b64})) {
|
||||
match self.run("file.open", json!({"name": &name, "base64": &b64})) {
|
||||
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
|
||||
Ok(r) => {
|
||||
self.backstage = None;
|
||||
self.show_open_warnings(&r);
|
||||
}
|
||||
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => {
|
||||
self.backstage = None;
|
||||
self.open_dialog("openPassword", json!({"name": name, "base64": b64}));
|
||||
}
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
@@ -941,6 +1006,7 @@ impl SheetApp {
|
||||
/// Lays out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.poll_printing(&ctx);
|
||||
if !self.fonts_ready || self.fonts_rebind {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
@@ -1031,21 +1097,25 @@ impl SheetApp {
|
||||
// Leave pointer cancellation to the popup so clicking a menu item still works.
|
||||
egui::Popup::close_all(ctx);
|
||||
}
|
||||
// The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when
|
||||
// the prefix changes, not every frame (that would reset a tab the user picked meanwhile).
|
||||
let after = self.keytips.prefix();
|
||||
if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) {
|
||||
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
|
||||
self.ui.ribbon_collapsed = false;
|
||||
}
|
||||
for action in actions {
|
||||
match action {
|
||||
keytips::Action::Home => {
|
||||
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
|
||||
keytips::Action::Tab(tab) => {
|
||||
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
|
||||
self.ui.ribbon_collapsed = false;
|
||||
}
|
||||
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
|
||||
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
|
||||
// Reserved sequence: fires silently while its command is missing. When the
|
||||
// command lands in the catalog it goes live with no table edit.
|
||||
keytips::Action::Pending(id) => {
|
||||
if gridcraft_engine::find_command(id).is_some() {
|
||||
self.run_or_alert(id, json!({}));
|
||||
}
|
||||
}
|
||||
// Route to the ribbon control that owns this keytip: it opens its dropdown or
|
||||
// runs its click handler with the right params (the engine command alone would
|
||||
// be missing them).
|
||||
keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Localized print-page wording around the shared, backend-independent controls.
|
||||
use craft_ui::printing::Labels;
|
||||
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit};
|
||||
|
||||
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
|
||||
let labels = Labels {
|
||||
printer: &translate("Printer"),
|
||||
select_printer: &translate("Select a printer"),
|
||||
refresh: &translate("Refresh printers"),
|
||||
copies: &translate("Copies"),
|
||||
duplex: &translate("Two-sided printing"),
|
||||
printer_default: &translate("Printer default"),
|
||||
one_sided: &translate("One-sided"),
|
||||
long_edge: &translate("Two-sided: long edge"),
|
||||
short_edge: &translate("Two-sided: short edge"),
|
||||
grayscale: &translate("Grayscale"),
|
||||
};
|
||||
state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels);
|
||||
if let Some(message) = &state.message {
|
||||
ui.label(feedback(message, translate));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read print settings/status, or validate an explicit update before changing anything.
|
||||
/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog.
|
||||
pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result<serde_json::Value, String> {
|
||||
use serde_json::{Value, json};
|
||||
let object = params.as_object().ok_or("print settings must be an object")?;
|
||||
if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) {
|
||||
return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into());
|
||||
}
|
||||
if state.busy() && !object.is_empty() {
|
||||
return Err("wait for the active print submission before changing settings".into());
|
||||
}
|
||||
let mut next = state.settings.clone();
|
||||
if let Some(value) = object.get("printer") {
|
||||
let printer = value.as_str().ok_or("printer must be a name from the discovered list")?;
|
||||
if !state.printers.iter().any(|available| available.name == printer) {
|
||||
return Err("printer must be a name from the discovered list".into());
|
||||
}
|
||||
next.printer = printer.to_owned();
|
||||
}
|
||||
if let Some(value) = object.get("copies") {
|
||||
let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?;
|
||||
next.copies = copies as u32;
|
||||
}
|
||||
if let Some(value) = object.get("duplex") {
|
||||
next.duplex = match value.as_str() {
|
||||
Some("printerDefault") => None,
|
||||
Some("off") => Some(Duplex::Off),
|
||||
Some("longEdge") => Some(Duplex::LongEdge),
|
||||
Some("shortEdge") => Some(Duplex::ShortEdge),
|
||||
None if value.is_null() => None,
|
||||
_ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()),
|
||||
};
|
||||
}
|
||||
if let Some(value) = object.get("grayscale") {
|
||||
next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?;
|
||||
}
|
||||
let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false);
|
||||
if refresh {
|
||||
let discover = discover.ok_or("native printer discovery is unavailable here")?;
|
||||
if !state.refresh(discover) {
|
||||
return Err("printer discovery or submission is already active".into());
|
||||
}
|
||||
}
|
||||
state.settings = next;
|
||||
let (status, detail) = match state.message.as_ref() {
|
||||
None => ("idle", ""),
|
||||
Some(Feedback::FindingPrinters) => ("findingPrinters", ""),
|
||||
Some(Feedback::NoPrinters) => ("noPrinters", ""),
|
||||
Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""),
|
||||
Some(Feedback::Submitting) => ("submitting", ""),
|
||||
Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()),
|
||||
Some(Feedback::Failed(error)) => ("failed", error.as_str()),
|
||||
Some(Feedback::WorkerStopped) => ("workerStopped", ""),
|
||||
};
|
||||
Ok(json!({
|
||||
"printer": state.settings.printer, "copies": state.settings.copies,
|
||||
"duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" },
|
||||
"grayscale": state.settings.grayscale,
|
||||
"printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::<Vec<Value>>(),
|
||||
"busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String {
|
||||
match message {
|
||||
Feedback::FindingPrinters => translate("Finding printers…"),
|
||||
Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."),
|
||||
Feedback::DiscoveryFailed => translate("Printer discovery failed."),
|
||||
Feedback::Submitting => translate("Submitting print job…"),
|
||||
Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."),
|
||||
Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt),
|
||||
Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error),
|
||||
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
|
||||
}
|
||||
}
|
||||
+577
-177
File diff suppressed because it is too large
Load Diff
@@ -145,9 +145,83 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f3
|
||||
}
|
||||
|
||||
/// Our colour palette (theme row + tints + standard colours), returns a picked hex colour,
|
||||
/// `Some("none")` for No Fill/Automatic.
|
||||
/// `Some("none")` for No Fill/Automatic. Arrow keys move a highlight through the swatches and
|
||||
/// Enter applies; the key events are stripped so the grid behind the popup does not also react.
|
||||
pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option<String> {
|
||||
let mut picked = None;
|
||||
const ROWS: usize = 7; // 6 tint rows + 1 standard row
|
||||
const COLS: usize = 10;
|
||||
let pal_id = ui.id().with("__color_palette");
|
||||
let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None);
|
||||
let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false);
|
||||
ui.input(|i| {
|
||||
dl = i.key_pressed(egui::Key::ArrowLeft);
|
||||
dr = i.key_pressed(egui::Key::ArrowRight);
|
||||
du = i.key_pressed(egui::Key::ArrowUp);
|
||||
dd = i.key_pressed(egui::Key::ArrowDown);
|
||||
ent = i.key_pressed(egui::Key::Enter);
|
||||
});
|
||||
if dl || dr || du || dd {
|
||||
cur = match cur {
|
||||
None => Some((
|
||||
0,
|
||||
if dr {
|
||||
1
|
||||
} else if dl {
|
||||
COLS - 1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)),
|
||||
Some((r, c)) => {
|
||||
let r2 = if du {
|
||||
r.saturating_sub(1)
|
||||
} else if dd {
|
||||
(r + 1).min(ROWS - 1)
|
||||
} else {
|
||||
r
|
||||
};
|
||||
let c2 = if dl {
|
||||
(c + COLS - 1) % COLS
|
||||
} else if dr {
|
||||
(c + 1) % COLS
|
||||
} else {
|
||||
c
|
||||
};
|
||||
Some((r2, c2))
|
||||
}
|
||||
};
|
||||
}
|
||||
if dl || dr || du || dd || ent {
|
||||
ui.ctx().input_mut(|i| {
|
||||
i.events.retain(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
egui::Event::Key {
|
||||
key: egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown | egui::Key::Enter,
|
||||
pressed: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
let theme_hex = |base: u32, tint: f64| -> String {
|
||||
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
|
||||
let c = gridcraft_engine::model::style::apply_tint(rgb, tint);
|
||||
let (r0, r1, r2) = (c[0], c[1], c[2]);
|
||||
format!("#{r0:02X}{r1:02X}{r2:02X}")
|
||||
};
|
||||
let std_hex = ["#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050", "#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0"];
|
||||
if ent && let Some((r, c)) = cur {
|
||||
picked = Some(if r < ROWS - 1 {
|
||||
let base = theme_colors.get(c).copied().unwrap_or(0);
|
||||
theme_hex(base, [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5][r])
|
||||
} else {
|
||||
std_hex[c].to_string()
|
||||
});
|
||||
ui.data_mut(|d| d.remove_temp::<Option<(usize, usize)>>(pal_id));
|
||||
}
|
||||
if ui.button(none_label).clicked() {
|
||||
picked = Some("none".to_string());
|
||||
}
|
||||
@@ -155,20 +229,20 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
|
||||
let order = [0usize, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let tints = [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5];
|
||||
egui::Grid::new(ui.id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
for tint in tints {
|
||||
for &i in &order {
|
||||
for (row, tint) in tints.iter().enumerate() {
|
||||
for (col, &i) in order.iter().enumerate() {
|
||||
let base = theme_colors.get(i).copied().unwrap_or(0);
|
||||
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
|
||||
let c = gridcraft_engine::model::style::apply_tint(rgb, tint);
|
||||
let col = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let c = gridcraft_engine::model::style::apply_tint(rgb, *tint);
|
||||
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 1.0, col);
|
||||
ui.painter().rect_filled(r, 1.0, col32);
|
||||
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
|
||||
if resp.hovered() {
|
||||
if resp.hovered() || cur == Some((row, col)) {
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]));
|
||||
picked = Some(theme_hex(base, *tint));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -177,18 +251,22 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
|
||||
ui.label(egui::RichText::new("Standard Colors").small());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for hex in ["#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050", "#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0"] {
|
||||
let col = gridcraft_engine::model::Color::from_hex(hex)
|
||||
for (col, hex) in std_hex.iter().enumerate() {
|
||||
let c = gridcraft_engine::model::Color::from_hex(hex)
|
||||
.and_then(|c| c.resolve(&Default::default()))
|
||||
.map(theme::color32)
|
||||
.unwrap_or(Color32::BLACK);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 1.0, col);
|
||||
ui.painter().rect_filled(r, 1.0, c);
|
||||
if resp.hovered() || cur == Some((ROWS - 1, col)) {
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(hex.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(pal_id, cur));
|
||||
picked
|
||||
}
|
||||
|
||||
|
||||
@@ -142,3 +142,21 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() {
|
||||
frames(&ctx, &mut app);
|
||||
assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_hint_shows_the_function_description() {
|
||||
// #363: inside DATEDIF( the hint lists the units the third argument takes.
|
||||
let ctx = Context::default();
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
frames(&ctx, &mut app);
|
||||
app.begin_edit(Some("=DATEDIF(A1,B1,".into()), false);
|
||||
frames(&ctx, &mut app);
|
||||
let output = frame(&ctx, &mut app);
|
||||
let texts: Vec<&egui::epaint::TextShape> =
|
||||
output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }).collect();
|
||||
assert!(texts.iter().any(|t| t.galley.text() == "DATEDIF("), "signature painted");
|
||||
let desc = texts.iter().find(|t| t.galley.text().contains("YM or YD")).expect("description with the units painted");
|
||||
assert!(desc.galley.rect.width() <= 400.0, "description wraps instead of widening the hint: {}", desc.galley.rect.width());
|
||||
}
|
||||
|
||||
@@ -152,3 +152,42 @@ fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line
|
||||
assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_filter_autofit_works_through_command_and_header_double_click() {
|
||||
for zoom in [50, 100, 200] {
|
||||
for double_click in [false, true] {
|
||||
let mut h = harness("A:A");
|
||||
h.state_mut().session.execute("range.setValues", json!({"range":"A1", "values":[["Quarterly Revenue"], ["1"], ["2"]]})).unwrap();
|
||||
h.state_mut().session.execute("insert.table", json!({"range":"A1:A3", "header":true})).unwrap();
|
||||
h.state_mut().session.execute("home.rowHeight", json!({"rows":"1:1", "height":20})).unwrap();
|
||||
h.state_mut().session.execute("view.zoom", json!({"percent":zoom})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.active_mut().unwrap().undo.clear();
|
||||
h.state_mut().session.journal.clear();
|
||||
if double_click {
|
||||
let (pos, _) = edge(&h, false, 0);
|
||||
button(&mut h, pos, true);
|
||||
button(&mut h, pos, false);
|
||||
h.step();
|
||||
button(&mut h, pos, true);
|
||||
button(&mut h, pos, false);
|
||||
} else {
|
||||
h.state_mut().run("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
|
||||
}
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let cell = gridcraft_engine::core::CellRef::new(0, 0);
|
||||
let rect = geo.cell_rect(sheet, cell);
|
||||
let button = gridcraft_ui_egui::grid::filter_button_rect(&geo, sheet, cell);
|
||||
let style = app.session.active().unwrap().wb.styles.get(sheet.style_id(cell));
|
||||
let text_width = gridcraft_engine::cmd::format::approx_text_width("Quarterly Revenue", style.font.size, style.font.bold) * geo.z;
|
||||
assert!(rect.left() + text_width + 2.5 * geo.z < button.left(), "heading overlaps filter: zoom={zoom}, double_click={double_click}");
|
||||
assert_eq!(app.session.active().unwrap().undo.len(), 1);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(sizes(&h, false)[0], 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() {
|
||||
#[test]
|
||||
fn legacy_paste_special_keeps_formulas_when_requested() {
|
||||
let mut h = harness(copied_row());
|
||||
// The legacy Edit route opens the dialog directly; F is a dialog mnemonic.
|
||||
sequence(&mut h, &[Key::E, Key::S], false);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
|
||||
letter(&mut h, Key::F, false);
|
||||
@@ -295,6 +296,80 @@ fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
|
||||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_letters_switch_to_their_ribbon_tabs() {
|
||||
let mut h = harness(blank());
|
||||
for (k, tab) in [(Key::N, "Insert"), (Key::P, "Page Layout"), (Key::M, "Formulas"), (Key::A, "Data"), (Key::R, "Review"), (Key::W, "View")] {
|
||||
key(&mut h, Key::F10); // arm
|
||||
letter(&mut h, k, false);
|
||||
assert_eq!(h.state().ui.ribbon_tab, tab, "letter {k:?}");
|
||||
assert!(h.state().editor.is_none(), "tab letters must not begin a cell edit");
|
||||
key(&mut h, Key::F10); // disarm
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
}
|
||||
// A terminal tab unit with no deeper sequences finishes and clears.
|
||||
key(&mut h, Key::F10); // re-arm
|
||||
letter(&mut h, Key::J, false);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("J"), "J waits for its multi-letter unit");
|
||||
letter(&mut h, Key::I, false);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Draw");
|
||||
assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_gridlines_sequence_runs_end_to_end() {
|
||||
let mut h = harness(blank());
|
||||
let gridlines = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().show_gridlines;
|
||||
assert!(gridlines(&h), "gridlines start visible");
|
||||
key(&mut h, Key::F10);
|
||||
letter(&mut h, Key::W, false);
|
||||
// W has deeper sequences: it stays as an active level so View badges show.
|
||||
assert_eq!(h.state().ui.ribbon_tab, "View");
|
||||
assert_eq!(h.state().keytips.prefix(), Some("W"));
|
||||
letter(&mut h, Key::V, false);
|
||||
assert_eq!(h.state().keytips.prefix(), Some("WV"), "implicit group level waits for the last key");
|
||||
letter(&mut h, Key::G, false);
|
||||
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
|
||||
assert!(!gridlines(&h), "WVG toggles gridlines off");
|
||||
key(&mut h, Key::F10);
|
||||
for k in [Key::W, Key::V, Key::G] {
|
||||
letter(&mut h, k, false);
|
||||
}
|
||||
assert!(gridlines(&h), "WVG toggles gridlines back on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_letter_unit_waits_after_first_letter_then_fires() {
|
||||
let mut h = harness(blank());
|
||||
key(&mut h, Key::F10);
|
||||
letter(&mut h, Key::J, false);
|
||||
// "J" is not a complete sequence, but "JI" starts with it: the matcher waits.
|
||||
assert_eq!(h.state().keytips.prefix(), Some("J"));
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Home");
|
||||
letter(&mut h, Key::I, false);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Draw");
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
assert!(h.state().editor.is_none(), "unit letters must not begin a cell edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_letter_unit_fires_when_pressed_in_one_frame() {
|
||||
let mut h = harness(blank());
|
||||
key(&mut h, Key::F10);
|
||||
events(
|
||||
&mut h,
|
||||
[
|
||||
key_event(Key::J, true, Modifiers::NONE),
|
||||
Event::Text("j".into()),
|
||||
key_event(Key::I, true, Modifiers::NONE),
|
||||
Event::Text("i".into()),
|
||||
key_event(Key::J, false, Modifiers::NONE),
|
||||
key_event(Key::I, false, Modifiers::NONE),
|
||||
],
|
||||
);
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Draw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() {
|
||||
let mut h = harness(blank());
|
||||
@@ -327,3 +402,151 @@ fn vertical_alignment_keytips_run_their_commands() {
|
||||
assert_eq!(h.state().session.journal.iter().filter(|(j, _)| j == id).count(), 1, "HA{} runs {id}", letter.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_sequences_are_silent_no_ops() {
|
||||
let mut h = harness(blank());
|
||||
let journal = h.state().session.journal.len();
|
||||
sequence(&mut h, &[Key::N, Key::W], false); // NW -> insert.wordArt (missing)
|
||||
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
|
||||
assert!(h.state().message.is_none(), "no message box for a pending sequence");
|
||||
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
|
||||
assert_eq!(h.state().session.journal.len(), journal, "no journal entry for a pending sequence");
|
||||
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_sequences_go_live_when_the_command_lands() {
|
||||
// The command lands in the catalog (here: a test-registered stand-in).
|
||||
// The sequence must run it with no table edit.
|
||||
gridcraft_engine::register_test_command("insert.equation", |s, _| {
|
||||
s.execute("cell.set", json!({"cell": "Z9", "input": "live"}))?;
|
||||
Ok(json!({}))
|
||||
});
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::N, Key::E], false); // NE -> insert.equation
|
||||
assert_eq!(value(&h, "Z9"), Value::from("live"), "the pending sequence ran the landed command");
|
||||
assert_eq!(
|
||||
h.state().session.journal.iter().filter(|(id, _)| id == "insert.equation").count(),
|
||||
1,
|
||||
"the landed command is journaled like any live command"
|
||||
);
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_keytips_open_dropdowns_and_run_handlers() {
|
||||
// HFF routes to the font control: its dropdown opens (popup items queryable).
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFF must not raise an error box");
|
||||
// "Calibri" appears twice: once on the combo button (the current font) and
|
||||
// once inside the opened dropdown — a single node would mean it stayed closed.
|
||||
assert!(h.query_all_by_label("Calibri").count() >= 2, "HFF must open the font dropdown");
|
||||
|
||||
// WVF toggles the formula bar: pure UI state, no engine command exists.
|
||||
let mut h = harness(blank());
|
||||
assert!(h.state().session.prefs.formula_bar);
|
||||
sequence(&mut h, &[Key::W, Key::V, Key::F], false);
|
||||
assert!(!h.state().session.prefs.formula_bar, "WVF must toggle the formula bar off");
|
||||
|
||||
// WJ runs the 100% handler with its percent param ({} would raise "missing percent").
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::W, Key::J], false);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "WJ must run view.zoom with the percent param");
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_combos_filter_by_typing_and_move_by_arrows() {
|
||||
// Open the font dropdown (HFF), type to filter, then Enter to apply.
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
|
||||
h.run_steps(3); // let the filter field gain focus
|
||||
events(&mut h, [Event::Text("arial".into())]);
|
||||
h.run_steps(3);
|
||||
// "arial" matches Arial only — Georgia must be filtered out of the list.
|
||||
assert_eq!(h.query_all_by_label("Georgia").count(), 0, "typing must narrow the list");
|
||||
assert!(h.query_all_by_label("Arial").count() >= 1, "the match must stay visible");
|
||||
key(&mut h, Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "applying must not raise an error");
|
||||
// The popup closed and the combo button now shows the applied font.
|
||||
assert_eq!(h.query_all_by_label("Arial").count(), 1, "Enter must apply Arial and close the popup");
|
||||
assert_eq!(h.query_all_by_label("Georgia").count(), 0);
|
||||
|
||||
// Arrows alone move through the full list: Calibri -> Cambria -> Candara -> Consolas.
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
|
||||
h.run_steps(3);
|
||||
for _ in 0..3 {
|
||||
key(&mut h, Key::ArrowDown);
|
||||
}
|
||||
key(&mut h, Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none());
|
||||
assert_eq!(h.query_all_by_label("Consolas").count(), 1, "Down x3 from Calibri must land on Consolas");
|
||||
|
||||
// Size combo: type a custom size not in the list, Enter applies it.
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::S], false);
|
||||
h.run_steps(3);
|
||||
events(&mut h, [Event::Text("13.5".into())]);
|
||||
key(&mut h, Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "custom sizes must apply");
|
||||
assert_eq!(h.query_all_by_label("13.5").count(), 1, "the button must show the custom size");
|
||||
|
||||
// Closing without applying (Escape) and reopening must reset the filter to the full list.
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
|
||||
h.run_steps(3);
|
||||
events(&mut h, [Event::Text("arial".into())]);
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::Escape);
|
||||
h.run_steps(3);
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
|
||||
h.run_steps(4);
|
||||
assert!(h.query_all_by_label("Georgia").count() >= 1, "reopening must show the full, unfiltered list");
|
||||
// Arrows after typing move the highlight, not the text cursor: type a filter,
|
||||
// then Down twice and Enter must apply the second match's neighbour logic.
|
||||
key(&mut h, Key::Escape);
|
||||
h.run_steps(3);
|
||||
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
|
||||
h.run_steps(3);
|
||||
events(&mut h, [Event::Text("candara".into())]);
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.query_all_by_label("Candara").count(), 1, "filtered Enter must apply the filtered row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_palette_navigates_with_arrows_and_enter_applies() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, &[Key::H, Key::H], false); // H,H opens the fill palette
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::ArrowDown); // highlight starts at theme row 0
|
||||
key(&mut h, Key::ArrowRight); // -> row 0, col 1 = Craft theme dk1 (#000000)
|
||||
key(&mut h, Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "arrow+Enter must not error");
|
||||
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1, "Enter applies the highlighted swatch");
|
||||
assert_eq!(h.state().grid.last_fill, "#000000", "the applied fill is the highlighted colour");
|
||||
assert_eq!(h.query_all_by_label("Theme Colors").count(), 0, "the palette closes after applying");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sequence_terminates_without_panicking() {
|
||||
use gridcraft_ui_egui::keytips::keytip_table::SEQUENCES;
|
||||
for (seq, _entry) in SEQUENCES {
|
||||
let mut h = harness(blank());
|
||||
key(&mut h, Key::F10); // arm
|
||||
for ch in seq.chars() {
|
||||
let k = Key::from_name(&ch.to_string()).expect("sequence letters map to keys");
|
||||
letter(&mut h, k, false);
|
||||
}
|
||||
// A finish/tab/dialog/wait state is all fine; the point is nothing panics.
|
||||
}
|
||||
assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
//! Native print onboarding preserves engine PDF generation and submits the selected settings.
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::printing::{Duplex, Feedback, Printer};
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
use serde_json::json;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
#[test]
|
||||
fn native_print_uses_selected_settings_and_reports_spool_errors() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Services::default());
|
||||
app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap();
|
||||
let jobs = Rc::new(RefCell::new(Vec::new()));
|
||||
let observed = jobs.clone();
|
||||
app.services.print_native = Some(Box::new(move |bytes, settings| {
|
||||
observed.borrow_mut().push((bytes, settings));
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(Err("printer is offline".into())).unwrap();
|
||||
receiver
|
||||
}));
|
||||
app.run_or_alert("file.print", json!({}));
|
||||
assert!(app.backstage.is_some(), "a shortcut opens settings without submitting");
|
||||
let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap();
|
||||
assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting");
|
||||
assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool");
|
||||
app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false });
|
||||
let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap();
|
||||
assert_eq!(configured["ready"], true);
|
||||
assert_eq!(configured["printers"][0]["name"], "Chosen queue");
|
||||
app.run("ui.printSubmit", json!({"sheets":"active"})).unwrap();
|
||||
assert!(app.printing.busy());
|
||||
let jobs = jobs.borrow();
|
||||
assert_eq!(jobs.len(), 1);
|
||||
assert!(jobs[0].0.starts_with(b"%PDF"));
|
||||
assert_eq!(jobs[0].1.printer, "Chosen queue");
|
||||
assert_eq!(jobs[0].1.copies, 3);
|
||||
assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge));
|
||||
assert!(jobs[0].1.grayscale);
|
||||
app.poll_printing(&egui::Context::default());
|
||||
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
|
||||
assert!(app.message.as_ref().unwrap().1.contains("printer is offline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_settings_are_visible_in_a_narrow_window() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Services::default());
|
||||
app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap();
|
||||
app.services.printers = Some(Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
|
||||
receiver
|
||||
}));
|
||||
app.services.print_native = Some(Box::new(|_, _| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(Ok("queued".into())).unwrap();
|
||||
receiver
|
||||
}));
|
||||
app.run("ui.backstage", json!({"page":"print"})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(5);
|
||||
assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled());
|
||||
assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled());
|
||||
harness.get_by_label("Grayscale").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().printing.settings.grayscale);
|
||||
assert!(harness.state().printing.ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_print_settings_are_rejected_without_partial_changes() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Services::default());
|
||||
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
|
||||
let before = app.run("ui.printSettings", json!({})).unwrap();
|
||||
for params in [
|
||||
json!({"printer":"Missing"}),
|
||||
json!({"copies":0}),
|
||||
json!({"copies":1000}),
|
||||
json!({"copies":1.5}),
|
||||
json!({"copies":-1}),
|
||||
json!({"copies":"3"}),
|
||||
json!({"duplex":"maybe"}),
|
||||
json!({"grayscale":"yes"}),
|
||||
json!({"refresh":"yes"}),
|
||||
json!({"copies":3,"duplex":"invalid"}),
|
||||
json!([]),
|
||||
] {
|
||||
assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}");
|
||||
assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_fast_jobs_each_surface_an_identical_receipt() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Services::default());
|
||||
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
|
||||
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
|
||||
app.services.print_native = Some(Box::new(|_, _| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(Ok("same receipt".into())).unwrap();
|
||||
receiver
|
||||
}));
|
||||
for _ in 0..2 {
|
||||
app.toast = None;
|
||||
app.run("ui.printSubmit", json!({})).unwrap();
|
||||
// Completion may be consumed by the shared widget before the app's logic pass.
|
||||
app.printing.poll(&egui::Context::default());
|
||||
app.poll_printing(&egui::Context::default());
|
||||
assert!(app.toast.as_ref().unwrap().0.contains("same receipt"));
|
||||
app.toast = None;
|
||||
app.poll_printing(&egui::Context::default());
|
||||
assert!(app.toast.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Opening a password-protected workbook asks for its password (#11).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||||
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
|
||||
use serde_json::json;
|
||||
|
||||
const PW: &str = "synthetic-pw-8842";
|
||||
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, services),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `PW`.
|
||||
fn encrypted_bytes() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
let xlsx = gridcraft_engine::io::save_bytes(&s.active().unwrap().wb, "book.xlsx").unwrap();
|
||||
gridcraft_xlsx::testing::encrypt_workbook(&xlsx, PW)
|
||||
}
|
||||
|
||||
/// `encrypted_bytes()` written to `Secret.xlsx` in a fresh temp dir named after `tag`.
|
||||
fn encrypted_file(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-ui-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Secret.xlsx");
|
||||
std::fs::write(&path, encrypted_bytes()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn dialog(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<String> {
|
||||
h.state().dialog.as_ref().map(|d| d.name().to_string())
|
||||
}
|
||||
|
||||
fn a1(h: &egui_kittest::Harness<'static, SheetApp>) -> Value {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_encrypted_file_asks_for_its_password() {
|
||||
let path = encrypted_file("desktop");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().open_path(&p);
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
assert!(h.state().message.is_none(), "no error box: the dialog asks instead");
|
||||
assert!(h.query_by_role(Role::PasswordInput).is_some(), "the password is masked");
|
||||
|
||||
// A wrong password says so, clears the field, and keeps the dialog and its file.
|
||||
h.get_by_role(Role::PasswordInput).click();
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("wrong-guess".into()));
|
||||
h.run_steps(2);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default();
|
||||
assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}");
|
||||
assert!(!msg.contains("wrong-guess"), "the password is never quoted: {msg}");
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!("")));
|
||||
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("path")), Some(&json!(p)));
|
||||
h.state_mut().message = None;
|
||||
|
||||
// The right one opens the workbook: no save path, in Recent, with the notice.
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(a1(&h), Value::Number(42.0));
|
||||
assert_eq!(h.state().session.active().unwrap().path, None);
|
||||
assert_eq!(h.state().ui.recent.first(), Some(&p));
|
||||
assert!(h.state().notice.as_ref().is_some_and(|n| n.text.contains("password-protected")));
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_leaves_nothing_open() {
|
||||
let path = encrypted_file("cancel");
|
||||
let mut h = harness(Services::default());
|
||||
let before = h.state().session.documents().len();
|
||||
h.state_mut().open_path(&path.to_string_lossy());
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
h.get_by_label(" Cancel ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(h.state().session.documents().len(), before);
|
||||
assert!(h.state().message.is_none());
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_picked_on_the_web_asks_for_its_password_too() {
|
||||
let inbox: Inbox = Default::default();
|
||||
let mut h = harness(Services { inbox: Some(inbox.clone()), ..Default::default() });
|
||||
inbox.lock().unwrap().push(("Secret.xlsx".into(), encrypted_bytes()));
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
assert!(h.state().dialog.as_ref().is_some_and(|d| d.values.contains_key("base64")));
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(dialog(&h), None);
|
||||
assert_eq!(a1(&h), Value::Number(42.0));
|
||||
assert_eq!(h.state().session.active().unwrap().path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_password_field_is_incorrect_not_required() {
|
||||
let path = encrypted_file("untouched");
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().open_path(&path.to_string_lossy());
|
||||
h.run_steps(3);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default();
|
||||
assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}");
|
||||
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_backstage_closes_once_the_password_opens_the_workbook() {
|
||||
let path = encrypted_file("backstage");
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().backstage = Some(gridcraft_ui_egui::backstage::Backstage::new(gridcraft_ui_egui::backstage::Page::Open));
|
||||
h.state_mut().open_path(&path.to_string_lossy());
|
||||
h.run_steps(3);
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(a1(&h), Value::Number(42.0));
|
||||
assert!(h.state().backstage.is_none(), "the opened workbook shows, not the Backstage");
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_open_over_the_control_channel_never_opens_a_dialog() {
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
let path = encrypted_file("control");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut h = harness(Services::default());
|
||||
let ctx = h.ctx.clone();
|
||||
let reply = |o: Outcome| match o {
|
||||
Outcome::Done(v) => v,
|
||||
Outcome::Screenshot { .. } => json!(null),
|
||||
};
|
||||
let r = reply(handle(h.state_mut(), &ctx, "app.open", &json!({"path": p})));
|
||||
assert_eq!(r["ok"], json!(false), "{r}");
|
||||
assert!(r["error"].as_str().unwrap_or_default().contains(gridcraft_engine::io::PASSWORD_REQUIRED), "{r}");
|
||||
assert_eq!(dialog(&h), None, "programmatic calls never open dialogs");
|
||||
let r = reply(handle(h.state_mut(), &ctx, "app.open", &json!({"path": p, "password": PW})));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(r["result"]["title"], json!("Secret.xlsx"));
|
||||
assert_eq!(a1(&h), Value::Number(42.0));
|
||||
assert_eq!(h.state().ui.recent.first(), Some(&p));
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
@@ -8,6 +8,11 @@ description = "GridCraft file formats: XLSX (SpreadsheetML) and CSV import/expor
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Builds password-encrypted workbooks in code for other crates' tests (a dev-dependency feature;
|
||||
# never enabled in a shipped build).
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
gridcraft-core = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
@@ -15,3 +20,6 @@ gridcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
@@ -83,7 +83,8 @@ impl Scan<'_> {
|
||||
let child = self.expr(x, sheet, array_context, depth + 1);
|
||||
match op {
|
||||
UnOp::At => Shape { array: false, affected: child.affected },
|
||||
UnOp::Spill => Shape { array: true, affected: child.affected },
|
||||
// A trim reference is a reference operator, like `:`.
|
||||
UnOp::Spill | UnOp::Trim(_) => Shape { array: true, affected: child.affected },
|
||||
_ => Shape { array: child.array, affected: child.affected || (child.array && !array_context) },
|
||||
}
|
||||
}
|
||||
|
||||
+117
-1
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_formula::{Expr, SheetSel};
|
||||
use gridcraft_model::{Chart, ChartKind, Color, LegendPos, Series, Workbook};
|
||||
use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook};
|
||||
|
||||
use crate::drawing::{dml_color, srgb};
|
||||
use crate::xml::{El, esc, num};
|
||||
@@ -107,9 +107,61 @@ fn read_series(s: &El) -> Series {
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: s.child("smooth").and_then(|e| e.attr("val")).and_then(|v| v.parse::<u8>().ok()).is_some_and(|v| v != 0),
|
||||
trendlines: s.kids("trendline").filter_map(read_trendline).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
const DASHES: [(LineDash, &str); 11] = [
|
||||
(LineDash::Solid, "solid"),
|
||||
(LineDash::Dot, "dot"),
|
||||
(LineDash::Dash, "dash"),
|
||||
(LineDash::LgDash, "lgDash"),
|
||||
(LineDash::DashDot, "dashDot"),
|
||||
(LineDash::LgDashDot, "lgDashDot"),
|
||||
(LineDash::LgDashDotDot, "lgDashDotDot"),
|
||||
(LineDash::SysDash, "sysDash"),
|
||||
(LineDash::SysDot, "sysDot"),
|
||||
(LineDash::SysDashDot, "sysDashDot"),
|
||||
(LineDash::SysDashDotDot, "sysDashDotDot"),
|
||||
];
|
||||
|
||||
const EMU_PER_PT: f64 = 12_700.0;
|
||||
|
||||
/// A `CT_Boolean` child (`val` defaults to true).
|
||||
fn bool_val(e: &El, tag: &str) -> bool {
|
||||
e.child(tag).is_some_and(|c| c.flag("val", true))
|
||||
}
|
||||
|
||||
fn f64_val(e: &El, tag: &str) -> Option<f64> {
|
||||
e.child(tag).and_then(|c| c.attr_f64("val"))
|
||||
}
|
||||
|
||||
/// `<c:trendline>` (ECMA-376 Part 1, 21.2.2.211). Unknown types are dropped.
|
||||
fn read_trendline(t: &El) -> Option<Trendline> {
|
||||
let kind = match t.child_val("trendlineType").unwrap_or("linear") {
|
||||
"linear" => TrendlineKind::Linear,
|
||||
"exp" => TrendlineKind::Exponential,
|
||||
"log" => TrendlineKind::Logarithmic,
|
||||
"power" => TrendlineKind::Power,
|
||||
"poly" => TrendlineKind::Polynomial(t.child_val("order").and_then(|v| v.trim().parse::<u8>().ok()).unwrap_or(2).clamp(2, 6)),
|
||||
"movingAvg" => TrendlineKind::MovingAverage(t.child_val("period").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(2).clamp(2, 255)),
|
||||
_ => return None,
|
||||
};
|
||||
let ln = t.path(&["spPr", "ln"]);
|
||||
Some(Trendline {
|
||||
kind,
|
||||
name: t.child("name").map(|n| n.text.clone()),
|
||||
forward: f64_val(t, "forward").unwrap_or(0.0).max(0.0),
|
||||
backward: f64_val(t, "backward").unwrap_or(0.0).max(0.0),
|
||||
intercept: f64_val(t, "intercept"),
|
||||
display_equation: bool_val(t, "dispEq"),
|
||||
display_r_squared: bool_val(t, "dispRSqr"),
|
||||
color: ln.and_then(|l| l.child("solidFill")).and_then(dml_color),
|
||||
width: ln.and_then(|l| l.attr_f64("w")).map(|w| (w / EMU_PER_PT) as f32).filter(|w| w.is_finite() && *w >= 0.0),
|
||||
dash: ln.and_then(|l| l.child("prstDash")).and_then(|d| d.attr("val")).and_then(|v| DASHES.iter().find(|(_, n)| *n == v).map(|(d, _)| *d)),
|
||||
})
|
||||
}
|
||||
|
||||
fn rich_title(t: &El) -> Option<String> {
|
||||
let tx = t.child("tx")?;
|
||||
if let Some(rich) = tx.child("rich") {
|
||||
@@ -343,6 +395,13 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
|
||||
G::Bubble => x.push_str("<c:invertIfNegative val=\"0\"/>"),
|
||||
_ => {}
|
||||
}
|
||||
// Trendlines sit between dLbls and the data in CT_LineSer, CT_BarSer, CT_AreaSer,
|
||||
// CT_ScatterSer and CT_BubbleSer; pie and radar series have none.
|
||||
if !matches!(g, G::Pie | G::Doughnut | G::Radar) {
|
||||
for t in &s.trendlines {
|
||||
x.push_str(&trendline_xml(t, wb));
|
||||
}
|
||||
}
|
||||
match g {
|
||||
G::Scatter { .. } | G::Bubble => {
|
||||
if let Some(c) = &s.categories {
|
||||
@@ -370,6 +429,63 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
|
||||
x
|
||||
}
|
||||
|
||||
/// `<c:trendline>` with children in schema order: name, spPr, trendlineType, order, period,
|
||||
/// forward, backward, intercept, dispRSqr, dispEq, trendlineLbl.
|
||||
fn trendline_xml(t: &Trendline, wb: &Workbook) -> String {
|
||||
let mut x = String::from("<c:trendline>");
|
||||
if let Some(n) = &t.name {
|
||||
let _ = write!(x, "<c:name>{}</c:name>", esc(n));
|
||||
}
|
||||
let color = t.color.as_ref().and_then(|c| srgb(c, &wb.theme));
|
||||
if color.is_some() || t.width.is_some() || t.dash.is_some() {
|
||||
x.push_str("<c:spPr><a:ln");
|
||||
if let Some(w) = t.width.filter(|w| w.is_finite()) {
|
||||
// ST_LineWidth: 0..=20116800 EMU.
|
||||
let _ = write!(x, " w=\"{}\"", (f64::from(w) * EMU_PER_PT).round().clamp(0.0, 20_116_800.0) as i64);
|
||||
}
|
||||
x.push_str(" cap=\"rnd\">");
|
||||
if let Some(c) = &color {
|
||||
let _ = write!(x, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
if let Some(d) = t.dash.and_then(|d| DASHES.iter().find(|(k, _)| *k == d)) {
|
||||
let _ = write!(x, "<a:prstDash val=\"{}\"/>", d.1);
|
||||
}
|
||||
x.push_str("</a:ln></c:spPr>");
|
||||
}
|
||||
let ty = match t.kind {
|
||||
TrendlineKind::Linear => "linear",
|
||||
TrendlineKind::Exponential => "exp",
|
||||
TrendlineKind::Logarithmic => "log",
|
||||
TrendlineKind::Polynomial(_) => "poly",
|
||||
TrendlineKind::Power => "power",
|
||||
TrendlineKind::MovingAverage(_) => "movingAvg",
|
||||
};
|
||||
let _ = write!(x, "<c:trendlineType val=\"{ty}\"/>");
|
||||
match t.kind {
|
||||
TrendlineKind::Polynomial(o) => {
|
||||
let _ = write!(x, "<c:order val=\"{}\"/>", o.clamp(2, 6));
|
||||
}
|
||||
TrendlineKind::MovingAverage(p) => {
|
||||
let _ = write!(x, "<c:period val=\"{}\"/>", p.clamp(2, 255));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (tag, v) in [("forward", t.forward), ("backward", t.backward)] {
|
||||
if v.is_finite() && v > 0.0 {
|
||||
let _ = write!(x, "<c:{tag} val=\"{}\"/>", num(v));
|
||||
}
|
||||
}
|
||||
if let Some(i) = t.intercept.filter(|i| i.is_finite()) {
|
||||
let _ = write!(x, "<c:intercept val=\"{}\"/>", num(i));
|
||||
}
|
||||
let _ = write!(x, "<c:dispRSqr val=\"{}\"/><c:dispEq val=\"{}\"/>", t.display_r_squared as u8, t.display_equation as u8);
|
||||
if t.display_r_squared || t.display_equation {
|
||||
x.push_str("<c:trendlineLbl><c:numFmt formatCode=\"General\" sourceLinked=\"0\"/></c:trendlineLbl>");
|
||||
}
|
||||
x.push_str("</c:trendline>");
|
||||
x
|
||||
}
|
||||
|
||||
fn dlbls(show: bool) -> String {
|
||||
format!(
|
||||
"<c:dLbls><c:showLegendKey val=\"0\"/><c:showVal val=\"{}\"/><c:showCatName val=\"0\"/><c:showSerName val=\"0\"/><c:showPercent val=\"0\"/><c:showBubbleSize val=\"0\"/></c:dLbls>",
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with
|
||||
//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells
|
||||
//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption
|
||||
//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and
|
||||
//! certificate-only encryption are reported as unsupported.
|
||||
//!
|
||||
//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is
|
||||
//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped.
|
||||
|
||||
use aes::cipher::{BlockCipherDecrypt, KeyInit};
|
||||
use sha2::Digest;
|
||||
|
||||
use crate::cfb::Compound;
|
||||
use crate::xml::El;
|
||||
|
||||
/// Spin counts above this are refused (the cap sheet-protection hashes use too).
|
||||
const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
/// Standard encryption always iterates this many times.
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
/// Agile packages are encrypted in segments of this many bytes, each with its own IV.
|
||||
pub(crate) const SEGMENT: usize = 4096;
|
||||
/// AES block size.
|
||||
const BLOCK: usize = 16;
|
||||
|
||||
/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password.
|
||||
pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79];
|
||||
pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E];
|
||||
pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6];
|
||||
|
||||
/// The password key encryptor's URI (the other kind is certificate-based).
|
||||
pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
|
||||
|
||||
/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1).
|
||||
pub(crate) const F_CRYPTOAPI: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
pub(crate) const F_AES: u32 = 0x20;
|
||||
|
||||
/// Why an encrypted workbook couldn't be decrypted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum DecryptError {
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
/// The scheme, e.g. "RC4" or "Extensible", to complete "uses {0} encryption".
|
||||
#[error("{0} encryption isn't supported")]
|
||||
Unsupported(String),
|
||||
#[error("the encrypted file is damaged ({0})")]
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
fn malformed(m: impl Into<String>) -> DecryptError {
|
||||
DecryptError::Malformed(m.into())
|
||||
}
|
||||
|
||||
/// The plain OOXML package (a zip) inside an encrypted workbook.
|
||||
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let (info, package) = streams(bytes)?;
|
||||
decrypt_streams(&info, &package, password)
|
||||
}
|
||||
|
||||
/// Whether the right password could decrypt this encrypted workbook, checked without one. An
|
||||
/// error names a scheme no password opens (RC4, rights management…) or damage, so callers can
|
||||
/// say so instead of asking for a password first.
|
||||
pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> {
|
||||
let (info, _) = streams(bytes)?;
|
||||
match scheme(&info)? {
|
||||
Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()),
|
||||
Scheme::Standard(info) => standard_header(info).map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the
|
||||
/// package but describes its encryption elsewhere, with no `EncryptionInfo`.
|
||||
fn streams(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), DecryptError> {
|
||||
let cf = Compound::open(bytes).map_err(malformed)?;
|
||||
let info = cf.root_stream("EncryptionInfo").map_err(malformed)?;
|
||||
let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?;
|
||||
let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?;
|
||||
Ok((info, package))
|
||||
}
|
||||
|
||||
/// Decrypts from the two streams' contents.
|
||||
pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
match scheme(info)? {
|
||||
Scheme::Agile(xml) => agile(xml, package, password),
|
||||
Scheme::Standard(info) => standard(info, package, password),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two schemes read, with the part of `EncryptionInfo` each one parses.
|
||||
enum Scheme<'a> {
|
||||
/// The XML descriptor after the version and reserved field.
|
||||
Agile(&'a [u8]),
|
||||
/// Everything after the version.
|
||||
Standard(&'a [u8]),
|
||||
}
|
||||
|
||||
fn scheme(info: &[u8]) -> Result<Scheme<'_>, DecryptError> {
|
||||
let short = || malformed("truncated EncryptionInfo");
|
||||
let major = u16_at(info, 0).ok_or_else(short)?;
|
||||
let minor = u16_at(info, 2).ok_or_else(short)?;
|
||||
match (major, minor) {
|
||||
(4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)),
|
||||
(2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)),
|
||||
(3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())),
|
||||
_ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger
|
||||
/// than the ciphertext is damage, so it never drives an allocation.
|
||||
fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> {
|
||||
let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?;
|
||||
let data = package.get(8..).unwrap_or_default();
|
||||
let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX);
|
||||
if size > data.len() {
|
||||
return Err(malformed("the package is shorter than its stated size"));
|
||||
}
|
||||
Ok((size, data))
|
||||
}
|
||||
|
||||
// ---- hashing -------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Hash {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha384,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl Hash {
|
||||
fn from_name(name: &str) -> Result<Hash, DecryptError> {
|
||||
Ok(match name {
|
||||
"SHA1" | "SHA-1" => Hash::Sha1,
|
||||
"SHA256" | "SHA-256" => Hash::Sha256,
|
||||
"SHA384" | "SHA-384" => Hash::Sha384,
|
||||
"SHA512" | "SHA-512" => Hash::Sha512,
|
||||
other => return Err(DecryptError::Unsupported(format!("{other} hash"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// The digest of the concatenated `parts`.
|
||||
pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
|
||||
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
for p in parts {
|
||||
d.update(p);
|
||||
}
|
||||
d.finalize().to_vec()
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => run::<sha1::Sha1>(parts),
|
||||
Hash::Sha256 => run::<sha2::Sha256>(parts),
|
||||
Hash::Sha384 => run::<sha2::Sha384>(parts),
|
||||
Hash::Sha512 => run::<sha2::Sha512>(parts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds.
|
||||
pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
|
||||
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut h = hash.digest(&[salt, &pw]);
|
||||
for i in 0..spin {
|
||||
h = hash.digest(&[&i.to_le_bytes(), &h]);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs.
|
||||
pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut k = hash.digest(&[a, b]);
|
||||
k.resize(len, 0x36);
|
||||
k
|
||||
}
|
||||
|
||||
/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the
|
||||
/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`.
|
||||
pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec<u8> {
|
||||
let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT);
|
||||
let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]);
|
||||
let expand = |pad: u8| {
|
||||
let mut buf = [pad; 64];
|
||||
for (b, x) in buf.iter_mut().zip(&h) {
|
||||
*b ^= x;
|
||||
}
|
||||
Hash::Sha1.digest(&[&buf])
|
||||
};
|
||||
let mut key = expand(0x36);
|
||||
key.extend(expand(0x5C));
|
||||
key.truncate(key_len);
|
||||
key
|
||||
}
|
||||
|
||||
// ---- AES -----------------------------------------------------------------------------------
|
||||
|
||||
enum Aes {
|
||||
A128(aes::Aes128),
|
||||
A192(aes::Aes192),
|
||||
A256(aes::Aes256),
|
||||
}
|
||||
|
||||
impl Aes {
|
||||
fn new(key: &[u8]) -> Result<Aes, DecryptError> {
|
||||
let bad = |_| malformed("bad AES key");
|
||||
Ok(match key.len() {
|
||||
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?),
|
||||
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?),
|
||||
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?),
|
||||
n => return Err(malformed(format!("a {n}-byte AES key"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_block(&self, block: &mut [u8; BLOCK]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match self {
|
||||
Aes::A128(c) => c.decrypt_block(b),
|
||||
Aes::A192(c) => c.decrypt_block(b),
|
||||
Aes::A256(c) => c.decrypt_block(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// ECB: each block on its own. `data` must be whole blocks.
|
||||
fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
blocks.iter_mut().for_each(|b| self.decrypt_block(b));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CBC: each block XORed with the previous ciphertext block (the IV for the first).
|
||||
fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?;
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
for b in blocks {
|
||||
let cipher = *b;
|
||||
self.decrypt_block(b);
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
prev = cipher;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Standard encryption -------------------------------------------------------------------
|
||||
|
||||
/// What a Standard `EncryptionInfo` holds: the key size and the password verifier.
|
||||
struct StandardHeader<'a> {
|
||||
key_len: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
/// `info` starts after the version: flags, header size, header, verifier.
|
||||
fn standard_header(info: &[u8]) -> Result<StandardHeader<'_>, DecryptError> {
|
||||
let short = || malformed("truncated Standard encryption header");
|
||||
let flags = u32_at(info, 0).ok_or_else(short)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(DecryptError::Unsupported("external".into()));
|
||||
}
|
||||
if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 {
|
||||
return Err(DecryptError::Unsupported("RC4".into()));
|
||||
}
|
||||
let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?;
|
||||
let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?;
|
||||
let alg_id = u32_at(header, 8).ok_or_else(short)?;
|
||||
let alg_hash = u32_at(header, 12).ok_or_else(short)?;
|
||||
let key_bits = u32_at(header, 16).ok_or_else(short)?;
|
||||
let key_len = match (alg_id, key_bits) {
|
||||
(0x660E | 0, 128) => 16,
|
||||
(0x660F, 192) => 24,
|
||||
(0x6610, 256) => 32,
|
||||
(0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))),
|
||||
_ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))),
|
||||
};
|
||||
if alg_hash != 0x8004 && alg_hash != 0 {
|
||||
return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}")));
|
||||
}
|
||||
// Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash.
|
||||
let v = info.get(8 + header_size..).ok_or_else(short)?;
|
||||
if u32_at(v, 0) != Some(16) {
|
||||
return Err(malformed("a Standard salt that isn't 16 bytes"));
|
||||
}
|
||||
let salt = v.get(4..20).ok_or_else(short)?;
|
||||
let verifier = v.get(20..36).ok_or_else(short)?;
|
||||
let verifier_hash = v.get(40..72).ok_or_else(short)?;
|
||||
Ok(StandardHeader { key_len, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
fn standard(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let h = standard_header(info)?;
|
||||
let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec());
|
||||
let aes = Aes::new(&standard_key(password, h.salt, h.key_len))?;
|
||||
aes.decrypt_ecb(&mut verifier)?;
|
||||
aes.decrypt_ecb(&mut verifier_hash)?;
|
||||
if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec();
|
||||
aes.decrypt_ecb(&mut out)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- Agile encryption ----------------------------------------------------------------------
|
||||
|
||||
/// The cipher parameters shared by `keyData` and a password `encryptedKey` element.
|
||||
pub(crate) struct Params {
|
||||
pub(crate) hash: Hash,
|
||||
pub(crate) salt: Vec<u8>,
|
||||
pub(crate) key_len: usize,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn read(e: &El) -> Result<Params, DecryptError> {
|
||||
let cipher = e.attr("cipherAlgorithm").unwrap_or("AES");
|
||||
if cipher != "AES" {
|
||||
return Err(DecryptError::Unsupported(cipher.into()));
|
||||
}
|
||||
let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC");
|
||||
if chaining != "ChainingModeCBC" {
|
||||
return Err(DecryptError::Unsupported(format!("AES {chaining}")));
|
||||
}
|
||||
if e.attr_u32("blockSize") != Some(16) {
|
||||
return Err(malformed("an AES block size that isn't 16"));
|
||||
}
|
||||
let key_len = match e.attr_u32("keyBits") {
|
||||
Some(128) => 16,
|
||||
Some(192) => 24,
|
||||
Some(256) => 32,
|
||||
other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))),
|
||||
};
|
||||
let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?;
|
||||
let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?;
|
||||
if salt.is_empty() || salt.len() > 64 {
|
||||
return Err(malformed("a salt that isn't 1 to 64 bytes"));
|
||||
}
|
||||
Ok(Params { hash, salt, key_len })
|
||||
}
|
||||
|
||||
/// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block.
|
||||
pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec<u8> {
|
||||
match block_key {
|
||||
Some(k) => derive(self.hash, &self.salt, k, BLOCK),
|
||||
None => {
|
||||
let mut iv = self.salt.clone();
|
||||
iv.resize(BLOCK, 0x36);
|
||||
iv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its
|
||||
/// three encrypted values (verifier input, verifier hash, package key).
|
||||
struct AgileDescriptor {
|
||||
key_data: Params,
|
||||
key: Params,
|
||||
spin: u32,
|
||||
verifier_input: Vec<u8>,
|
||||
verifier_hash: Vec<u8>,
|
||||
key_value: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
|
||||
fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
|
||||
let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?;
|
||||
let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?;
|
||||
let encryptor = root
|
||||
.child("keyEncryptors")
|
||||
.and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR)))
|
||||
.ok_or_else(|| DecryptError::Unsupported("certificate".into()))?
|
||||
.child("encryptedKey")
|
||||
.ok_or_else(|| malformed("no encryptedKey"))?;
|
||||
let key = Params::read(encryptor)?;
|
||||
let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?;
|
||||
if spin > MAX_SPIN_COUNT {
|
||||
return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed")));
|
||||
}
|
||||
let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?);
|
||||
Ok(AgileDescriptor {
|
||||
key_data,
|
||||
key,
|
||||
spin,
|
||||
verifier_input: field("encryptedVerifierHashInput")?,
|
||||
verifier_hash: field("encryptedVerifierHashValue")?,
|
||||
key_value: field("encryptedKeyValue")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?;
|
||||
let h = iterated_hash(key.hash, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |data: &[u8], block_key: &[u8]| -> Result<Vec<u8>, DecryptError> {
|
||||
let mut data = data.to_vec();
|
||||
Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?;
|
||||
Ok(data)
|
||||
};
|
||||
let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?;
|
||||
let expected = field(&verifier_hash, &BLOCK_VERIFIER_HASH)?;
|
||||
let input = input.get(..key.salt.len()).unwrap_or(&input);
|
||||
let actual = key.hash.digest(&[input]);
|
||||
if expected.get(..actual.len()) != Some(actual.as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let secret = field(&key_value, &BLOCK_KEY)?;
|
||||
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
for (i, segment) in data.chunks(SEGMENT).enumerate() {
|
||||
if out.len() >= size {
|
||||
break;
|
||||
}
|
||||
let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?;
|
||||
let start = out.len();
|
||||
out.extend_from_slice(segment);
|
||||
let block = out.get_mut(start..).unwrap_or_default();
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?;
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- base64 --------------------------------------------------------------------------------
|
||||
|
||||
/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value.
|
||||
fn base64(s: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut buf, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => return Err(malformed("invalid base64")),
|
||||
};
|
||||
buf = (buf << 6) | u32::from(v);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
buf &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Decryption tests. The expected keys and IVs below were computed independently with
|
||||
//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted
|
||||
//! files are built in code by `crate::testing`. No Excel-produced file is used.
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header};
|
||||
|
||||
const PASSWORD: &str = "Pass1234";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn salt() -> Vec<u8> {
|
||||
(0u8..16).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_decrypts_the_fips_197_vectors() {
|
||||
let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_keys_match_msoffcrypto() {
|
||||
let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6"));
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03"));
|
||||
let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b"));
|
||||
let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 };
|
||||
assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_pads_short_hashes_with_0x36() {
|
||||
// SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow.
|
||||
let k = derive(Hash::Sha1, b"a", b"b", 32);
|
||||
assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]);
|
||||
assert_eq!(k[20..], [0x36; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_keys_match_msoffcrypto() {
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4"));
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_and_rejects_junk() {
|
||||
assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt());
|
||||
assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]);
|
||||
assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
/// A payload that isn't a whole number of segments or blocks.
|
||||
fn payload(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 7 + i / 251) as u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_standard(&plain, PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_round_trip() {
|
||||
// UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP.
|
||||
let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}";
|
||||
let (info, package) = encrypt_agile(&payload(100), pw, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), pw);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_password_is_reported_as_such() {
|
||||
let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword));
|
||||
assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_named() {
|
||||
let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) {
|
||||
Err(DecryptError::Unsupported(s)) => s,
|
||||
other => panic!("expected Unsupported, got {other:?}"),
|
||||
};
|
||||
let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD);
|
||||
rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES
|
||||
assert_eq!(unsupported(&rc4), "RC4");
|
||||
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
|
||||
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
|
||||
let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
|
||||
assert_eq!(unsupported(&with_agile_header(&des)), "3DES");
|
||||
let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_malformed_not_panics() {
|
||||
let is_malformed = |r: Result<Vec<u8>, DecryptError>| matches!(r, Err(DecryptError::Malformed(_)));
|
||||
let plain = payload(5000);
|
||||
for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] {
|
||||
// Every truncation of either stream.
|
||||
for cut in (0..info.len()).step_by(7) {
|
||||
let _ = decrypt_streams(&info[..cut], &package, PASSWORD);
|
||||
}
|
||||
for cut in (0..package.len()).step_by(97) {
|
||||
let _ = decrypt_streams(&info, &package[..cut], PASSWORD);
|
||||
}
|
||||
// A stated size past the ciphertext never drives an allocation.
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD)));
|
||||
// Ciphertext that isn't whole AES blocks.
|
||||
let mut ragged = package.clone();
|
||||
ragged.truncate(ragged.len() - 3);
|
||||
let len = (ragged.len() - 8) as u64;
|
||||
ragged[..8].copy_from_slice(&len.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
|
||||
}
|
||||
let xml = agile_info(PASSWORD, 1000);
|
||||
let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD));
|
||||
assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\"")));
|
||||
assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*")));
|
||||
assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\"")));
|
||||
assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\"")));
|
||||
assert!(bad(xml.replace("<keyData", "<other")));
|
||||
assert!(bad("<encryption".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
|
||||
// "Pässwörd€" plus an emoji outside the BMP, a surrogate pair in UTF-16.
|
||||
let h = iterated_hash(Hash::Sha512, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", &salt(), 1000);
|
||||
assert_eq!(
|
||||
h,
|
||||
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
|
||||
);
|
||||
}
|
||||
+7
-16
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::parse::parse_input;
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value, number_to_text};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value};
|
||||
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
|
||||
|
||||
use crate::{CsvOptions, IoError};
|
||||
@@ -139,6 +139,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
wb.date_system = opts.date_system;
|
||||
wb.csv_delimiter = u8::try_from(delim).ok();
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut styles = std::mem::take(&mut wb.styles);
|
||||
let mut fmt_cache: Vec<(&'static str, StyleId)> = vec![];
|
||||
@@ -183,20 +184,10 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
|
||||
Ok(wb)
|
||||
}
|
||||
|
||||
fn field_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Empty => String::new(),
|
||||
Value::Number(n) => number_to_text(*n),
|
||||
Value::Text(t) => t.to_string(),
|
||||
Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(),
|
||||
Value::Error(e) => e.as_str().into(),
|
||||
Value::Array(a) => a.get(0, 0).map(field_text).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the sheet's used range as CSV (raw values, CRLF line ends, UTF-8 without BOM).
|
||||
pub fn write_csv(sheet: &Sheet, wb: &Workbook, delimiter: u8) -> Vec<u8> {
|
||||
let _ = wb;
|
||||
/// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives
|
||||
/// each cell's field text: the caller decides how values look (Save As CSV writes them as
|
||||
/// displayed, number formats applied); quoting is done here.
|
||||
pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec<u8> {
|
||||
let delim = if delimiter == 0 { ',' } else { delimiter as char };
|
||||
let mut out = String::new();
|
||||
let Some(r) = sheet.used_range() else { return Vec::new() };
|
||||
@@ -205,7 +196,7 @@ pub fn write_csv(sheet: &Sheet, wb: &Workbook, delimiter: u8) -> Vec<u8> {
|
||||
if col > r.start.col {
|
||||
out.push(delim);
|
||||
}
|
||||
let t = field_text(&sheet.value(CellRef::new(row, col)));
|
||||
let t = cell_text(CellRef::new(row, col));
|
||||
if t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r') {
|
||||
out.push('"');
|
||||
out.push_str(&t.replace('"', "\"\""));
|
||||
|
||||
+27
-1
@@ -76,10 +76,19 @@ fn expr_to_file(e: Expr) -> String {
|
||||
Some(n) => Expr::Call(n, args),
|
||||
None => Expr::Call(name, args),
|
||||
},
|
||||
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`.
|
||||
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`, and a spill
|
||||
// reference that became `#REF!` (its cell was deleted) as plain `#REF!`.
|
||||
Expr::Unary(gridcraft_formula::UnOp::Spill, r) if matches!(*r, Expr::Error(_)) => *r,
|
||||
Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]),
|
||||
// Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`.
|
||||
Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]),
|
||||
// Nor trim-reference operators: written as the equivalent `TRIMRANGE(range,n,n)`, which
|
||||
// every Excel with trim references reads. (Excel's own file spelling of `A1:.E10` is
|
||||
// not publicly documented; the `_xlfn._TRO_*` call forms are accepted on read.)
|
||||
Expr::Unary(gridcraft_formula::UnOp::Trim(mode), x) => {
|
||||
let code = Expr::Number(f64::from(mode.code()));
|
||||
Expr::Call("_xlfn.TRIMRANGE".into(), vec![*x, code.clone(), code])
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
gridcraft_formula::print(&e)
|
||||
@@ -115,6 +124,11 @@ mod tests {
|
||||
assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)");
|
||||
assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)");
|
||||
assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#");
|
||||
// Found by the command-sequence fuzzer: deleting the anchor of `H5#` left `=#REF!` in
|
||||
// the workbook but `ANCHORARRAY(#REF!)` in the file, which read back differently.
|
||||
let deleted = Formula::from_expr(Expr::Unary(gridcraft_formula::UnOp::Spill, Box::new(Expr::Error(gridcraft_core::CellError::Ref))));
|
||||
assert_eq!(deleted.text, "#REF!");
|
||||
assert_eq!(to_file(&deleted), "#REF!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -126,6 +140,18 @@ mod tests {
|
||||
assert_eq!(from_file("SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"), "SUM(@A1:A3*B1:B3)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_references() {
|
||||
assert_eq!(to_file(&Formula::new("SUM(A1:.E10)+SUM(Data!A.:A)")), "SUM(_xlfn.TRIMRANGE(A1:E10,2,2))+SUM(_xlfn.TRIMRANGE(Data!A:A,1,1))");
|
||||
assert_eq!(text_to_file("=ROWS(1.:.5)"), "ROWS(_xlfn.TRIMRANGE(1:5,3,3))");
|
||||
assert_eq!(from_file("SUM(_xlfn._TRO_TRAILING(A1:E10))+_xlfn._TRO_ALL($A:$B)"), "SUM(A1:.E10)+$A.:.$B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbracketed_this_row_column() {
|
||||
assert_eq!(from_file("Sales[[#This Row],Units]*[[#This Row],Price]"), "Sales[@Units]*[@Price]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes() {
|
||||
assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)");
|
||||
|
||||
+57
-4
@@ -1,5 +1,5 @@
|
||||
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import
|
||||
//! and legacy Excel 97-2003 `.xls` (BIFF8) import.
|
||||
//! and legacy Excel 97-2003 `.xls` (BIFF8) import, and password-encrypted workbooks (Agile and Standard encryption).
|
||||
//!
|
||||
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
|
||||
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
|
||||
@@ -15,6 +15,7 @@ mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod crypto;
|
||||
mod csv;
|
||||
mod drawing;
|
||||
mod feature_bags;
|
||||
@@ -29,6 +30,8 @@ mod sheet_read;
|
||||
mod sheet_write;
|
||||
mod styles;
|
||||
mod tables;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod testing;
|
||||
mod theme;
|
||||
mod write;
|
||||
mod xls;
|
||||
@@ -41,6 +44,7 @@ mod tests;
|
||||
use gridcraft_core::DateSystem;
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
pub use crypto::{DecryptError, check_encryption, decrypt};
|
||||
pub use csv::{read_csv, write_csv};
|
||||
pub use ods::read_ods;
|
||||
pub use xls::read_xls;
|
||||
@@ -74,14 +78,63 @@ impl ReadReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an `.xlsx` / `.xlsm` package.
|
||||
/// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the
|
||||
/// package relationships, so every main-part content type is accepted).
|
||||
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
read::read_xlsx(bytes)
|
||||
}
|
||||
|
||||
/// Writes a workbook as an `.xlsx` package.
|
||||
/// Which member of the SpreadsheetML family a package is saved as. Only the content type of the
|
||||
/// workbook part differs (ECMA-376 Part 1 Workbook part; MS-OI29500 adds the macro-enabled types).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum XlsxKind {
|
||||
/// `.xlsx`
|
||||
#[default]
|
||||
Workbook,
|
||||
/// `.xlsm`
|
||||
MacroWorkbook,
|
||||
/// `.xltx`
|
||||
Template,
|
||||
/// `.xltm`
|
||||
MacroTemplate,
|
||||
}
|
||||
|
||||
impl XlsxKind {
|
||||
/// The kind a file name's extension asks for; anything else is a plain workbook.
|
||||
pub fn from_path(path: &str) -> XlsxKind {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"xlsm" => XlsxKind::MacroWorkbook,
|
||||
"xltx" => XlsxKind::Template,
|
||||
"xltm" => XlsxKind::MacroTemplate,
|
||||
_ => XlsxKind::Workbook,
|
||||
}
|
||||
}
|
||||
|
||||
/// Content type of `/xl/workbook.xml`.
|
||||
pub fn main_content_type(self) -> &'static str {
|
||||
match self {
|
||||
XlsxKind::Workbook => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
|
||||
XlsxKind::MacroWorkbook => "application/vnd.ms-excel.sheet.macroEnabled.main+xml",
|
||||
XlsxKind::Template => "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml",
|
||||
XlsxKind::MacroTemplate => "application/vnd.ms-excel.template.macroEnabled.main+xml",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this kind may carry a VBA project (`.xlsm`, `.xltm`).
|
||||
pub fn is_macro_enabled(self) -> bool {
|
||||
matches!(self, XlsxKind::MacroWorkbook | XlsxKind::MacroTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a workbook as an `.xlsx` package, or as an `.xlsm` one when it holds a VBA project.
|
||||
pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
|
||||
write::write_xlsx(wb)
|
||||
write::write_xlsx(wb, if wb.vba_project.is_some() { XlsxKind::MacroWorkbook } else { XlsxKind::Workbook })
|
||||
}
|
||||
|
||||
/// Writes a workbook as the given member of the `.xlsx` family.
|
||||
pub fn write_xlsx_as(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
||||
write::write_xlsx(wb, kind)
|
||||
}
|
||||
|
||||
/// CSV import options.
|
||||
|
||||
@@ -176,10 +176,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
};
|
||||
wb.calc.iterative = c.flag("iterate", false);
|
||||
if let Some(n) = c.attr_u32("iterateCount") {
|
||||
wb.calc.max_iterations = n.clamp(1, 32767);
|
||||
wb.calc.set_max_iterations(n);
|
||||
}
|
||||
if let Some(d) = c.attr_f64("iterateDelta") {
|
||||
wb.calc.max_change = d.abs();
|
||||
wb.calc.set_max_change(d);
|
||||
}
|
||||
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
if let Some(rel) = tp.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)) {
|
||||
let target = rel.target.clone();
|
||||
if let Some(x) = cx.optional_xml(&target)?
|
||||
&& let Some(mut t) = read_table(&x)
|
||||
&& let Some(mut t) = read_table(cx, &x)
|
||||
{
|
||||
t.id = cx.next_object_id();
|
||||
sheet.tables.push(t);
|
||||
@@ -454,7 +454,8 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
||||
}
|
||||
|
||||
fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option<Formula> {
|
||||
let text = f.text.trim();
|
||||
let model = from_file(f.text.trim());
|
||||
let text = model.as_str();
|
||||
match f.attr("t").unwrap_or("normal") {
|
||||
"shared" => {
|
||||
let si = f.attr_u32("si")?;
|
||||
@@ -777,6 +778,9 @@ pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option<AutoFilter> {
|
||||
} else if let Some(c) = fc.child("colorFilter") {
|
||||
let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue };
|
||||
if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) }
|
||||
} else if let Some(i) = fc.child("iconFilter") {
|
||||
let Some(set) = i.attr("iconSet") else { continue };
|
||||
FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") }
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
@@ -785,7 +789,7 @@ pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option<AutoFilter> {
|
||||
Some(AutoFilter { range, criteria })
|
||||
}
|
||||
|
||||
pub fn read_table(x: &El) -> Option<Table> {
|
||||
pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option<Table> {
|
||||
let range = x.attr("ref").and_then(RangeRef::parse)?;
|
||||
let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string();
|
||||
let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0;
|
||||
@@ -836,6 +840,7 @@ pub fn read_table(x: &El) -> Option<Table> {
|
||||
first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)),
|
||||
last_col: si.is_some_and(|s| s.flag("showLastColumn", false)),
|
||||
filter_button: x.child("autoFilter").is_some(),
|
||||
filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{
|
||||
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind,
|
||||
Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
|
||||
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind, Style, Table,
|
||||
TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
|
||||
};
|
||||
|
||||
use crate::drawing::{Obj, image_content_type, image_ext};
|
||||
@@ -35,10 +35,12 @@ fn cf_op_name(op: CfOperator) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid table name: letters, digits, `_` and `.`, starting with a letter or `_`.
|
||||
/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\`
|
||||
/// (Microsoft "Names in formulas" rules).
|
||||
fn table_name(name: &str, used: &mut HashSet<String>, n: u32) -> String {
|
||||
let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || c == '_' || c == '.' { c } else { '_' }).collect();
|
||||
if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') || gridcraft_core::CellRef::parse(&s).is_some() {
|
||||
let mut s: String =
|
||||
name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect();
|
||||
if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() {
|
||||
s = format!("Table{n}");
|
||||
}
|
||||
let mut out = s.clone();
|
||||
@@ -56,12 +58,7 @@ fn table_columns(t: &Table) -> Vec<String> {
|
||||
let mut used = HashSet::new();
|
||||
(0..w)
|
||||
.map(|i| {
|
||||
let base = t
|
||||
.columns
|
||||
.get(i)
|
||||
.map(|c| c.name.replace(['\r', '\n'], " "))
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("Column{}", i + 1));
|
||||
let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1));
|
||||
let mut name = base.clone();
|
||||
let mut k = 2;
|
||||
while !used.insert(name.to_lowercase()) {
|
||||
@@ -346,7 +343,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
if let Some(af) = &sheet.autofilter
|
||||
&& !sheet.tables.iter().any(|t| t.range.intersects(&af.range))
|
||||
{
|
||||
s.push_str(&autofilter_xml(af, out));
|
||||
s.push_str(&autofilter_xml(af.range, &af.criteria, out));
|
||||
}
|
||||
if !sheet.merges.is_empty() {
|
||||
let merges: Vec<&RangeRef> = sheet.merges.iter().filter(|m| !m.is_single()).collect();
|
||||
@@ -569,7 +566,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
out.table_id += 1;
|
||||
let n = out.tables;
|
||||
let name = table_name(&t.name, &mut used_names, out.table_id);
|
||||
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), table_xml(t, cols, &name, out.table_id).into_bytes());
|
||||
let xml = table_xml(t, cols, &name, out.table_id, out);
|
||||
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes());
|
||||
let rid = rels.add("table", &format!("../tables/table{n}.xml"));
|
||||
let _ = write!(parts, "<tablePart r:id=\"{rid}\"/>");
|
||||
}
|
||||
@@ -911,14 +909,14 @@ fn filter_operator(op: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn autofilter_xml(af: &AutoFilter, out: &mut Out) -> String {
|
||||
let mut s = format!("<autoFilter ref=\"{}\"", af.range.a1());
|
||||
if af.criteria.is_empty() {
|
||||
fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String {
|
||||
let mut s = format!("<autoFilter ref=\"{}\"", range.a1());
|
||||
if criteria.is_empty() {
|
||||
s.push_str("/>");
|
||||
return s;
|
||||
}
|
||||
s.push('>');
|
||||
for (col, c) in &af.criteria {
|
||||
for (col, c) in criteria {
|
||||
let _ = write!(s, "<filterColumn colId=\"{col}\">");
|
||||
match c {
|
||||
FilterCriterion::Values { values, blanks } => {
|
||||
@@ -951,6 +949,13 @@ fn autofilter_xml(af: &AutoFilter, out: &mut Out) -> String {
|
||||
st.font.color = *c;
|
||||
let _ = write!(s, "<colorFilter dxfId=\"{}\" cellColor=\"0\"/>", dxf_id(&mut out.dxfs, &st));
|
||||
}
|
||||
FilterCriterion::Icon { set, id } => {
|
||||
let _ = write!(s, "<iconFilter iconSet=\"{}\"", esc_attr(set));
|
||||
if let Some(id) = id {
|
||||
let _ = write!(s, " iconId=\"{id}\"");
|
||||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
}
|
||||
s.push_str("</filterColumn>");
|
||||
}
|
||||
@@ -958,7 +963,7 @@ fn autofilter_xml(af: &AutoFilter, out: &mut Out) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn table_xml(t: &Table, cols: &[String], name: &str, id: u32) -> String {
|
||||
fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String {
|
||||
let mut s =
|
||||
format!("{XML_DECL}<table xmlns=\"{NS_MAIN}\" id=\"{id}\" name=\"{0}\" displayName=\"{0}\" ref=\"{1}\"", esc_attr(name), t.range.a1());
|
||||
if !t.header_row {
|
||||
@@ -973,7 +978,7 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32) -> String {
|
||||
if t.header_row && t.filter_button {
|
||||
let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row };
|
||||
let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col));
|
||||
let _ = write!(s, "<autoFilter ref=\"{}\"/>", r.a1());
|
||||
s.push_str(&autofilter_xml(r, &t.filter, out));
|
||||
}
|
||||
let _ = write!(s, "<tableColumns count=\"{}\">", cols.len());
|
||||
for (i, name) in cols.iter().enumerate() {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
|
||||
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
|
||||
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
|
||||
//!
|
||||
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
|
||||
//! use: a fixed key protects nothing.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
|
||||
use crate::crypto::{
|
||||
BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash,
|
||||
standard_key,
|
||||
};
|
||||
|
||||
/// The spin count `encrypt_workbook` uses: Excel's default.
|
||||
pub const SPIN_COUNT: u32 = 100_000;
|
||||
|
||||
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
||||
const SECRET_KEY: [u8; 32] =
|
||||
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
|
||||
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
|
||||
|
||||
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
|
||||
/// (AES-256, SHA-512) inside a compound file.
|
||||
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
}
|
||||
|
||||
/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it.
|
||||
pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_standard(package, password);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
}
|
||||
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as
|
||||
/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular
|
||||
/// sectors. Up to 109 FAT sectors (about 7 MB of streams).
|
||||
pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
const SECTOR: usize = 512;
|
||||
const MINI: usize = 64;
|
||||
const CUTOFF: usize = 4096;
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END: u32 = 0xFFFF_FFFE;
|
||||
const FAT_SECTOR: u32 = 0xFFFF_FFFD;
|
||||
let is_mini = |d: &[u8]| d.len() < CUTOFF;
|
||||
|
||||
// The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT.
|
||||
let mut container = vec![];
|
||||
let mut mini_fat: Vec<u32> = vec![];
|
||||
let mut mini_starts = vec![];
|
||||
for (_, d) in streams {
|
||||
if !is_mini(d) || d.is_empty() {
|
||||
mini_starts.push(END);
|
||||
continue;
|
||||
}
|
||||
let first = mini_fat.len();
|
||||
let n = d.len().div_ceil(MINI);
|
||||
for k in 0..n {
|
||||
mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 });
|
||||
}
|
||||
mini_starts.push(first as u32);
|
||||
container.extend_from_slice(d);
|
||||
container.resize((first + n) * MINI, 0);
|
||||
}
|
||||
|
||||
// Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream.
|
||||
let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR;
|
||||
let mut blobs: Vec<Vec<u8>> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()];
|
||||
blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() }));
|
||||
let counts: Vec<usize> = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect();
|
||||
let body: usize = counts.iter().sum();
|
||||
let mut fat_sectors = 1;
|
||||
while fat_sectors * 128 < fat_sectors + body {
|
||||
fat_sectors += 1;
|
||||
}
|
||||
assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors");
|
||||
let mut fat = vec![FREE; fat_sectors * 128];
|
||||
fat[..fat_sectors].fill(FAT_SECTOR);
|
||||
let mut starts = vec![];
|
||||
let mut next = fat_sectors;
|
||||
for &n in &counts {
|
||||
if n == 0 {
|
||||
starts.push(END);
|
||||
continue;
|
||||
}
|
||||
starts.push(next as u32);
|
||||
for k in 0..n {
|
||||
fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 };
|
||||
}
|
||||
next += n;
|
||||
}
|
||||
|
||||
let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes());
|
||||
let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
// Directory: the root (owning the mini stream), then the streams chained as right siblings.
|
||||
let dir = &mut blobs[0];
|
||||
for k in 0..dir_len / 128 {
|
||||
for at in [68, 72, 76] {
|
||||
put32(dir, k * 128 + at, FREE);
|
||||
}
|
||||
}
|
||||
let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| {
|
||||
let e = k * 128;
|
||||
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
dir[e..e + units.len()].copy_from_slice(&units);
|
||||
put16(dir, e + 64, units.len() as u16 + 2);
|
||||
dir[e + 66] = kind;
|
||||
dir[e + 67] = 1; // black
|
||||
if kind == 5 && !streams.is_empty() {
|
||||
put32(dir, e + 76, 1);
|
||||
}
|
||||
if kind == 2 && k < streams.len() {
|
||||
put32(dir, e + 72, k as u32 + 1);
|
||||
}
|
||||
put32(dir, e + 116, start);
|
||||
put32(dir, e + 120, size as u32);
|
||||
};
|
||||
entry(0, "Root Entry", 5, starts[2], container.len());
|
||||
for (k, (name, data)) in streams.iter().enumerate() {
|
||||
let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] };
|
||||
entry(k + 1, name, 2, start, data.len());
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)];
|
||||
out[..8].copy_from_slice(&crate::cfb::SIGNATURE);
|
||||
put16(&mut out, 24, 0x3E); // minor version
|
||||
put16(&mut out, 26, 3); // major version
|
||||
put16(&mut out, 28, 0xFFFE); // byte order
|
||||
put16(&mut out, 30, 9); // 512-byte sectors
|
||||
put16(&mut out, 32, 6); // 64-byte mini sectors
|
||||
put32(&mut out, 44, fat_sectors as u32);
|
||||
put32(&mut out, 48, starts[0]);
|
||||
put32(&mut out, 56, CUTOFF as u32);
|
||||
put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none)
|
||||
put32(&mut out, 64, counts[1] as u32);
|
||||
put32(&mut out, 68, END); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE });
|
||||
}
|
||||
for (i, v) in fat.iter().enumerate() {
|
||||
put32(&mut out, SECTOR + i * 4, *v);
|
||||
}
|
||||
for (blob, start) in blobs.iter().zip(&starts) {
|
||||
if !blob.is_empty() {
|
||||
let at = SECTOR * (1 + *start as usize);
|
||||
out[at..at + blob.len()].copy_from_slice(blob);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn encrypt_block(key: &[u8], block: &mut [u8; 16]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match key.len() {
|
||||
16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
n => panic!("no {n}-byte AES in the test encryptor"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(data: &[u8]) -> Vec<u8> {
|
||||
let mut v = data.to_vec();
|
||||
v.resize(data.len().div_ceil(16) * 16, 0);
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b));
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
let mut prev: [u8; 16] = iv.try_into().unwrap();
|
||||
for b in v.as_chunks_mut::<16>().0 {
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
encrypt_block(key, b);
|
||||
prev = *b;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
|
||||
let mut p = (size as u64).to_le_bytes().to_vec();
|
||||
p.extend(body);
|
||||
p
|
||||
}
|
||||
|
||||
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16.
|
||||
pub(crate) fn agile_info(password: &str, spin: u32) -> String {
|
||||
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
|
||||
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data));
|
||||
let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER);
|
||||
let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER]));
|
||||
let secret = field(&BLOCK_KEY, &SECRET_KEY);
|
||||
let params = |salt: &[u8]| {
|
||||
format!(
|
||||
"saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"",
|
||||
base64_encode(salt)
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
|
||||
params(&KEY_DATA_SALT),
|
||||
params(&PASSWORD_SALT),
|
||||
)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML.
|
||||
pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
|
||||
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
|
||||
info.extend_from_slice(xml.as_bytes());
|
||||
info
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512).
|
||||
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
|
||||
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
|
||||
let mut body = vec![];
|
||||
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
|
||||
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
|
||||
}
|
||||
(with_agile_header(&agile_info(password, spin)), package(plain.len(), body))
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
|
||||
pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let key = standard_key(password, &PASSWORD_SALT, 16);
|
||||
let mut header = vec![];
|
||||
for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] {
|
||||
header.extend_from_slice(&u32::to_le_bytes(v));
|
||||
}
|
||||
header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let mut info = vec![4, 0, 2, 0];
|
||||
info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend(header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes());
|
||||
info.extend(PASSWORD_SALT);
|
||||
info.extend(encrypt_ecb(&key, &VERIFIER));
|
||||
info.extend_from_slice(&20u32.to_le_bytes());
|
||||
info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER])));
|
||||
(info, package(plain.len(), encrypt_ecb(&key, plain)))
|
||||
}
|
||||
@@ -183,6 +183,7 @@ fn calculated_table_column_keeps_formula_and_cell_value() {
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
});
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
@@ -330,6 +331,7 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() {
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
});
|
||||
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
|
||||
sh.cells.set(at("B2"), formula("[@Input]*2", 6.0));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user