Merge main into function updates

This commit is contained in:
Harrison Booth
2026-10-30 20:59:31 -05:00
313 changed files with 22604 additions and 4146 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: gridcraft
app-dir: GridCraft
+6 -1
View File
@@ -23,7 +23,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
@@ -70,3 +70,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+6 -1
View File
@@ -27,7 +27,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
@@ -51,3 +51,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+20 -17
View File
@@ -950,13 +950,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1935,7 +1936,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"arboard",
"chrono",
@@ -1962,7 +1963,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1974,7 +1975,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1985,7 +1986,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1997,7 +1998,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"chrono",
"js-sys",
@@ -2008,7 +2009,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -2024,12 +2025,13 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"web-time",
"zip 3.0.0",
]
[[package]]
name = "gridcraft-formula"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -2038,7 +2040,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -2046,7 +2048,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -2055,7 +2057,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2067,18 +2069,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.12.1"
version = "0.13.0"
[[package]]
name = "gridcraft-ui-egui"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -2097,12 +2099,13 @@ dependencies = [
"sys-locale",
"toml",
"web-sys",
"web-time",
"zbus",
]
[[package]]
name = "gridcraft-web"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"eframe",
"egui",
@@ -2119,7 +2122,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"aes",
"gridcraft-core",
@@ -6400,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"gridcraft-engine",
"serde_json",
+8 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.12.1"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -26,7 +26,8 @@ gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -34,7 +35,8 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
web-time = "1.1.0"
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.41"
@@ -67,10 +69,11 @@ craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-li
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
+7 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -86,7 +86,7 @@ collaboration, mobile, AI).
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
## Languages
@@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
@@ -179,6 +180,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
@@ -243,6 +245,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
+1 -1
View File
@@ -1,6 +1,6 @@
use super::calc::formula_text;
use super::package::{REBUILT_KINDS, relationship_kinds, validate_package};
use super::*;
use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet};
// ---------------------------------------------------------------- round trip
+3 -1
View File
@@ -20,7 +20,9 @@ fn local_name(name: &[u8]) -> &[u8] {
}
/// `(element local name, attributes)` for every start/empty element of an XML part.
fn xml_elements(xml: &[u8]) -> Result<Vec<(String, Vec<(String, String)>)>, String> {
type XmlElement = (String, Vec<(String, String)>);
fn xml_elements(xml: &[u8]) -> Result<Vec<XmlElement>, String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_reader(xml);
let mut buf = Vec::new();
+19 -3
View File
@@ -131,7 +131,7 @@ fn fit(s: &str, w: usize) -> String {
if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::<String>()) }
}
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
fn print_file_reports(reports: &[FileReport]) {
for r in reports {
outln!("== {}", r.path);
if let Some(e) = &r.error {
@@ -186,7 +186,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
}
}
}
outln!("");
}
fn print_file_table(reports: &[FileReport]) {
let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50);
outln!(
"{:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip",
@@ -220,7 +222,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
c.volatile
);
}
let t = totals(reports);
}
fn print_rankings(t: &Totals, opts: &Options) {
let top = ranked(&t.functions);
if !top.is_empty() {
outln!("");
@@ -239,6 +243,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
outln!(" {n:>8} {sig}");
}
}
}
fn print_totals(t: &Totals) {
outln!("");
outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed);
outln!(
@@ -262,3 +269,12 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) }
);
}
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
print_file_reports(reports);
outln!("");
print_file_table(reports);
let t = totals(reports);
print_rankings(&t, opts);
print_totals(&t);
}
-2
View File
@@ -1,5 +1,3 @@
//! The crate's integration tests, one module per file, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod text_to_columns_quotes;
+3 -2
View File
@@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
// With `noopener` the new browsing context can't hold a reference back to the
// editor window and navigate it while the user reads the linked page.
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
// editor window and navigate it while the user reads the linked page. It must be a
// window *feature*: inside the target string it would only name the new window.
let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer");
}
})),
inbox: Some(inbox),
+164 -15
View File
@@ -12,16 +12,47 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::time::{Duration, Instant};
use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
/// How long a new connection may take to send its token.
/// How long a new connection may take to send its token, in total.
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
/// How many connections may be waiting to authenticate at once. Anyone on the machine can
/// connect without the token, so past this a new connection is closed before anything is read:
/// each waiting one holds a thread and a socket.
const MAX_PENDING_AUTH: usize = 16;
/// How the server treats connections that haven't authenticated yet.
#[derive(Clone, Copy)]
struct AuthLimits {
timeout: Duration,
max_pending: usize,
}
const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };
/// A connection that is still waiting to authenticate; dropping it frees its place.
struct Pending(Arc<AtomicUsize>);
impl Pending {
/// A place among the `max` waiting connections, or `None` when they are all taken.
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;
Some(Pending(Arc::clone(waiting)))
}
}
impl Drop for Pending {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
/// else 256 random bits from the operating system, written to a file only the user can read.
@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
}
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
let waiting = Arc::new(AtomicUsize::new(0));
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
for stream in listener.incoming() {
let Ok(stream) = stream else {
// Out of sockets or handles: wait for some to free instead of spinning on the error.
std::thread::sleep(Duration::from_millis(50));
continue;
};
// Dropping the stream closes it before anything is read.
let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
let _ =
std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));
}
})?;
Ok(rx)
}
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
let mut first = String::new();
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
let verdict = match read {
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
/// `timeout` in total) and check the token. `true` when the peer may go on.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {
let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
let mut first = Vec::new();
let read = read_auth_line(reader, &mut first, deadline);
let verdict = match (read, std::str::from_utf8(&first)) {
(Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token),
_ => Err(json!({"ok": false, "error": "authentication required"})),
};
let allowed = verdict.is_ok();
let reply = verdict.unwrap_or_else(|e| e);
let _ = writeln!(out, "{reply}");
let _ = out.set_read_timeout(None);
let _ = reader.get_ref().set_read_timeout(None);
allowed
}
/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.
///
/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps
/// its own, so one set on the writing half never fired (a silent peer held its thread forever).
/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {
let max = control_auth::MAX_AUTH_LINE;
while line.len() < max && line.last() != Some(&b'\n') {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::ErrorKind::TimedOut.into());
}
reader.get_ref().set_read_timeout(Some(left))?;
let buf = reader.fill_buf()?;
if buf.is_empty() {
break;
}
let room = max.saturating_sub(line.len());
let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);
line.extend(buf.iter().take(take));
reader.consume(take);
}
Ok(())
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
if !authenticate(&mut reader, &mut out, token) {
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
// Authenticated or refused, the connection no longer counts as waiting.
drop(place);
if !allowed {
return;
}
let mut buf = Vec::new();
@@ -294,7 +368,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
}
});
addr
@@ -346,6 +420,81 @@ mod tests {
assert_eq!(got[1]["ok"], json!(false));
}
/// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within
/// `within`, and how long it took.
#[allow(clippy::disallowed_methods)] // a native-only test of the desktop app
fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {
let start = Instant::now();
c.set_read_timeout(Some(within)).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
loop {
line.clear();
match r.read_line(&mut line) {
Ok(0) => return (true, start.elapsed()),
Ok(_) => continue,
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),
Err(_) => return (true, start.elapsed()),
}
}
}
/// The token deadline is for the whole first line: neither a silent peer nor one sending a
/// byte at a time keeps the connection (and its thread) past it.
#[test]
fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let silent = TcpStream::connect(addr).unwrap();
let mut slow = TcpStream::connect(addr).unwrap();
let mut drip = slow.try_clone().unwrap();
std::thread::spawn(move || {
for b in control_auth::auth_line(1, TOKEN).bytes() {
if drip.write_all(&[b]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
});
let (closed, took) = closed_within(silent, Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");
let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");
let _ = slow.flush();
}
/// Peers without the token can't pile up threads: past the cap a new connection is closed at
/// once, and a waiting one that authenticates frees its place.
#[test]
fn connections_waiting_to_authenticate_are_capped() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let mut first = TcpStream::connect(addr).unwrap();
let _second = TcpStream::connect(addr).unwrap();
std::thread::sleep(Duration::from_millis(200));
let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");
first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");
std::thread::sleep(Duration::from_millis(200));
let mut next = TcpStream::connect(addr).unwrap();
next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(next).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");
}
#[test]
fn a_chosen_token_must_be_long_enough() {
assert!(make_token(1, Some("short".into())).is_err());
+4 -3
View File
@@ -268,9 +268,10 @@ impl log::Log for AppLogger {
}
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
let logged_at = gridcraft_engine::core::date::unix_now_ms()
.and_then(|milliseconds| SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(milliseconds.max(0.0) as u64)))
.unwrap_or(SystemTime::UNIX_EPOCH);
let line = format_line(&timestamp(logged_at), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(line.as_bytes());
+6 -5
View File
@@ -256,7 +256,7 @@ fn main() -> eframe::Result<()> {
}));
let args: Vec<String> = std::env::args().skip(1).collect();
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("GridCraft update helper failed: {e}");
std::process::exit(1);
@@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> {
{
log::warn!("sample {s}: {e}");
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.start_update_check();
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
// Stamp the startup workbook's author from the persisted user name.
if app.session.documents().is_empty() {
app.session.new_workbook();
}
app.after_engine(); // Show warnings from the sample.
// 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.
+8 -7
View File
@@ -390,11 +390,12 @@ impl NativeMenu {
#[cfg(test)]
mod tests {
use super::*;
// No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test
// harness, so the #[test] functions are left out and a module-level import would be unused.
#[test]
fn file_menu_has_export_as_pdf_dispatching_the_command() {
let tree = tree();
let tree = super::tree();
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
assert_eq!(item.1, "file.exportPdf");
@@ -403,23 +404,23 @@ mod tests {
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
let tree = tree();
let tree = super::tree();
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
assert_eq!(item.1, "app.checkForUpdates");
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
assert!(app.run(item.1, serde_json::json!({})).is_ok());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
for mode in ["dark", "system", "light"] {
assert!(select_theme(&mut app, &format!("theme:{mode}")));
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
assert_eq!(app.ui.theme_mode(), mode);
assert!(app.message.is_none());
}
assert!(!select_theme(&mut app, "view.formulaBar"));
assert!(!super::select_theme(&mut app, "view.formulaBar"));
assert_eq!(app.ui.theme_mode(), "light");
}
}
+13 -3
View File
@@ -7,7 +7,6 @@
// unit tests compile here too without a harness to run them).
#[cfg(target_os = "macos")]
#[path = "../src/native_menu.rs"]
#[allow(unused_imports, dead_code)]
mod native_menu;
#[cfg(target_os = "macos")]
@@ -20,12 +19,23 @@ fn main() {
nsapp.finishLaunching();
let ctx = egui::Context::default();
let _menu = native_menu::NativeMenu::install(&ctx);
let mut menu = native_menu::NativeMenu::install(&ctx);
let bar = nsapp.mainMenu().expect("a main menu");
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu");
assert_eq!(windows_menu.title().to_string(), "Window");
// A poll lists the open workbook windows at the end of the Window menu (#716).
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default());
menu.poll(&mut app, &ctx, egui::ViewportId::ROOT);
let open = app.open_windows();
assert_eq!(open.len(), 1, "one workbook window");
let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item");
assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook");
println!("mac_menu_appkit: ok");
}
+1
View File
@@ -14,3 +14,4 @@ disallowed-methods = [
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+6
View File
@@ -103,3 +103,9 @@ real_name = "Helen Kwok"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Plutonium-fream]
sync_github_name = true
+1 -1
View File
@@ -7,7 +7,7 @@
use std::sync::Arc;
use gridcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare};
use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
use gridcraft_formula::{BinOp, Expr, Reference, SheetSel, StructItem, StructRef, UnOp};
use gridcraft_functions::{Arg, Ctx};
use gridcraft_model::Workbook;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
/// The aggregates that can fold a reference without building an array.
#[derive(Clone, Copy, PartialEq, Eq)]
+158 -147
View File
@@ -1,40 +1,13 @@
use super::array::{Acc, Agg, zip2, zip3};
use super::format::same_kind;
use super::special::{ev_shape, resized_sum_range, resizes_sum_range};
use super::*;
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {
match name {
"IF" => {
if args.is_empty() || args.len() > 3 {
return err(CellError::Value);
}
let c = self.arg_val(args, 0).unwrap_or_default();
if let Value::Array(arr) = &c {
// Array condition: element-wise.
let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() {
Ok(true) => t.clone(),
Ok(false) => f.clone(),
Err(e) => Value::Error(e),
}));
}
match c.to_bool() {
Ok(true) => match args.get(1) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(e) => self.eval(e),
None => Ev::V(Value::Bool(true)),
},
Ok(false) => match args.get(2) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(e) => self.eval(e),
None => Ev::V(Value::Bool(false)),
},
Err(e) => err(e),
}
}
"IF" => self.if_call(args),
"IFS" => {
if args.len() < 2 || !args.len().is_multiple_of(2) {
return err(CellError::Value);
@@ -50,101 +23,10 @@ impl<'h> Evaluator<'h> {
}
err(CellError::NA)
}
"IFERROR" | "IFNA" => {
if args.len() != 2 {
return err(CellError::Value);
}
let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty));
let na_only = name == "IFNA";
let catch = |e: CellError| if na_only { e == CellError::NA } else { true };
match &v {
Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]),
Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => {
let alt = self.arg_val(args, 1).unwrap_or_default();
let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect();
Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
Ev::R(_) => {
let val = self.deref(v.clone());
match val {
Value::Error(e) if catch(e) => self.eval(&args[1]),
Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => {
let alt = self.arg_val(args, 1).unwrap_or_default();
let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect();
Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
_ => v,
}
}
_ => v,
}
}
"CHOOSE" => {
if args.len() < 2 {
return err(CellError::Value);
}
let index = self.value(&args[0]);
if let Value::Array(_) = index {
// An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)`
// is a two-column array. The library CHOOSE lifts over its arguments.
let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) };
let mut evaluated = vec![Arg { value: index, from_ref: false }];
for a in &args[1..] {
let value = self.value(a);
evaluated.push(Arg { value, from_ref: false });
}
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
}
let i = match index.to_number() {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
if i < 1 || i as usize >= args.len() {
return err(CellError::Value);
}
self.eval(&args[i as usize])
}
"SWITCH" => {
if args.len() < 3 {
return err(CellError::Value);
}
let v = self.arg_val(args, 0).unwrap_or_default().scalar();
if let Value::Error(e) = v {
return err(e);
}
let rest = &args[1..];
let mut i = 0;
while i + 1 < rest.len() {
let cand = self.value(&rest[i]).scalar();
if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) {
return self.eval(&rest[i + 1]);
}
i += 2;
}
if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) }
}
"ROW" | "COLUMN" => {
let is_row = name == "ROW";
if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) {
return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64));
}
match self.eval(&args[0]) {
Ev::R(a) => {
let Some(a) = a.first() else { return err(CellError::Ref) };
let r = self.trim_lines(a);
if is_row {
let rows: Vec<Value> = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect();
if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) }
} else {
let cols: Vec<Value> = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect();
if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) }
}
}
Ev::V(Value::Error(e)) => err(e),
_ => err(CellError::Value),
}
}
"IFERROR" | "IFNA" => self.if_error_call(name, args),
"CHOOSE" => self.choose_call(args),
"SWITCH" => self.switch_call(args),
"ROW" | "COLUMN" => self.row_column_call(name, args),
"ROWS" | "COLUMNS" => {
if args.len() != 1 {
return err(CellError::Value);
@@ -180,28 +62,7 @@ impl<'h> Evaluator<'h> {
}
}
}
"SHEET" | "SHEETS" => {
let wb = self.host.workbook();
if args.is_empty() {
return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64));
}
match self.eval(&args[0]) {
Ev::R(a) => {
if name == "SHEET" {
a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0)))
} else {
let mut s: Vec<usize> = a.iter().map(|x| x.sheet).collect();
s.dedup();
Ev::V(Value::number(s.len() as f64))
}
}
Ev::V(Value::Text(t)) if name == "SHEET" => {
let wb = self.host.workbook();
wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0)))
}
_ => err(CellError::NA),
}
}
"SHEET" | "SHEETS" => self.sheet_call(name, args),
"OFFSET" => self.offset(args),
"INDIRECT" => self.indirect(args),
"INDEX" => self.index(args),
@@ -273,6 +134,156 @@ impl<'h> Evaluator<'h> {
}
}
fn if_call(&mut self, args: &[Expr]) -> Ev {
if args.is_empty() || args.len() > 3 {
return err(CellError::Value);
}
let condition = self.arg_val(args, 0).unwrap_or_default();
if let Value::Array(array) = &condition {
let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() {
Ok(true) => when_true.clone(),
Ok(false) => when_false.clone(),
Err(error) => Value::Error(error),
}));
}
match condition.to_bool() {
Ok(true) => match args.get(1) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(expression) => self.eval(expression),
None => Ev::V(Value::Bool(true)),
},
Ok(false) => match args.get(2) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(expression) => self.eval(expression),
None => Ev::V(Value::Bool(false)),
},
Err(error) => err(error),
}
}
fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev {
if args.len() != 2 {
return err(CellError::Value);
}
let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty));
let na_only = name == "IFNA";
let catches = |error: CellError| !na_only || error == CellError::NA;
match &value {
Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]),
Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
let alternate = self.arg_val(args, 1).unwrap_or_default();
let data =
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
Ev::R(_) => self.if_error_reference(value.clone(), args, catches),
_ => value,
}
}
fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev {
match self.deref(value.clone()) {
Value::Error(error) if catches(error) => self.eval(&args[1]),
Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
let alternate = self.arg_val(args, 1).unwrap_or_default();
let data =
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
_ => value,
}
}
fn choose_call(&mut self, args: &[Expr]) -> Ev {
if args.len() < 2 {
return err(CellError::Value);
}
let index = self.value(&args[0]);
if let Value::Array(_) = index {
let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) };
let mut evaluated = vec![Arg { value: index, from_ref: false }];
for argument in &args[1..] {
evaluated.push(Arg { value: self.value(argument), from_ref: false });
}
let mut context = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V);
}
let index = match index.to_number() {
Ok(number) => number.trunc() as i64,
Err(error) => return err(error),
};
if index < 1 || index as usize >= args.len() {
return err(CellError::Value);
}
self.eval(&args[index as usize])
}
fn switch_call(&mut self, args: &[Expr]) -> Ev {
if args.len() < 3 {
return err(CellError::Value);
}
let value = self.arg_val(args, 0).unwrap_or_default().scalar();
if let Value::Error(error) = value {
return err(error);
}
let choices = &args[1..];
for pair in choices.as_chunks::<2>().0 {
let (candidate, result) = (&pair[0], &pair[1]);
let candidate = self.value(candidate).scalar();
if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) {
return self.eval(result);
}
}
if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) {
self.eval(default)
} else {
err(CellError::NA)
}
}
fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev {
let is_row = name == "ROW";
if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) {
return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64));
}
let areas = match self.eval(&args[0]) {
Ev::R(areas) => areas,
Ev::V(Value::Error(error)) => return err(error),
_ => return err(CellError::Value),
};
let Some(area) = areas.first() else { return err(CellError::Ref) };
let range = self.trim_lines(area);
if is_row {
let values: Vec<Value> = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect();
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) }
} else {
let values: Vec<Value> = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect();
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) }
}
}
fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev {
let is_sheet = name == "SHEET";
if args.is_empty() {
let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() };
return Ev::V(Value::number(count as f64));
}
match self.eval(&args[0]) {
Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))),
Ev::R(areas) => {
let mut sheets: Vec<usize> = areas.iter().map(|area| area.sheet).collect();
sheets.dedup();
Ev::V(Value::number(sheets.len() as f64))
}
Ev::V(Value::Text(name)) if is_sheet => {
self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0)))
}
_ => err(CellError::NA),
}
}
pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
let Some(spec) = gridcraft_functions::lookup(name) else {
// A defined name holding a LAMBDA, or a LET-bound lambda.
+2 -1
View File
@@ -1,4 +1,5 @@
use super::*;
use super::{CellError, Value};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
/// CELL("format") code for a number format.
pub(super) fn cell_format_code(code: &str) -> String {
+3 -1
View File
@@ -1,5 +1,7 @@
use super::MAX_LAMBDA_CALLS;
use super::array::{broadcast_len, single};
use super::*;
use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
+3 -1
View File
@@ -1,5 +1,7 @@
use super::format::{cell_format_code, r1c1_to_a1};
use super::*;
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
+2 -4
View File
@@ -1,4 +1,5 @@
use super::*;
use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool {
@@ -162,6 +163,3 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[
pub fn is_known_function(n: &str) -> bool {
is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some()
}
#[allow(dead_code)]
fn unused(_: RefKind) {}
+107 -110
View File
@@ -10,14 +10,13 @@
//! 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};
use std::collections::VecDeque;
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use gridcraft_model::{CalcMode, Workbook};
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
use crate::eval::{Evaluator, Host, precedents, stack_position};
mod graph;
mod pass_host;
@@ -368,105 +367,13 @@ impl Calc {
self.circular.retain(|k| !evaluated.contains(k));
}
self.circular.extend(cycles);
// Write back.
let mut spill_changes: Vec<Key> = Vec::new();
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
for (k, v) in results {
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
if let Some(cell) = sheet.cells.get_mut(k.1)
&& cell.value != v
{
cell.value = v;
}
}
for k in &dirty {
let new = spills.get(k);
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
let old = sheet.spill_ranges.get(&k.1).copied();
if old.is_none() && new.is_none() {
continue;
}
// An array that spills exactly as before changes nothing its dependents read, so
// they aren't queued again. Otherwise two arrays that read each other's spill areas
// queue each other in every round, up to the round limit.
if let (Some(r), Some(arr)) = (old, new)
&& spills_as_before(sheet, k.1, r, arr)
{
continue;
}
if let Some(r) = old {
for c in r.iter() {
sheet.spill.remove(&c);
}
sheet.spill_ranges.remove(&k.1);
}
let mut changed_range = old;
if let Some(arr) = new {
let end = CellRef::new(k.1.row.saturating_add(arr.rows as u32 - 1), k.1.col.saturating_add(arr.cols as u32 - 1));
let range = RangeRef::new(k.1, end);
let blocked = end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c)))
|| sheet.merges.iter().any(|m| m.intersects(&range))
|| sheet.tables.iter().any(|t| t.range.contains(k.1));
if blocked {
if let Some(cell) = sheet.cells.get_mut(k.1) {
cell.value = Value::Error(CellError::Spill);
}
// Not blocked while the pass ran (it would have been #SPILL! there), so
// blocked by another array of this pass: the formulas that read this one,
// at its anchor or over the area it would have spilled into, read #SPILL!
// or empty cells now.
let mut deps = Vec::new();
self.graph.dependents_of_range(k.0, range, &mut deps);
spill_changes.extend(deps.into_iter().filter(|d| d != k));
} else {
for (i, c) in range.iter().enumerate() {
if c != k.1 {
sheet.spill.insert(c, arr.data.get(i).cloned().unwrap_or_default());
}
}
sheet.spill_ranges.insert(k.1, range);
changed_range = Some(changed_range.map_or(range, |o| o.union(&range)));
}
}
if let Some(r) = changed_range {
let mut deps = Vec::new();
self.graph.dependents_of_range(k.0, r, &mut deps);
// Not the anchor itself (that would read its own array), unless its old array
// is gone or partly freed: its new formula may have read the old values, still
// laid out while it was evaluated.
let freed = match (old, sheet.spill_ranges.get(&k.1)) {
(Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)),
(_, None) => true,
(None, Some(_)) => false,
};
spill_changes.extend(deps.into_iter().filter(|d| d != k || freed));
}
if let Some(o) = old
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
{
freed.push((k.0, o));
}
}
let mut spill_changes = self.write_back(wb, &dirty, results, &spills);
// Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read
// a spill area before it was laid out: they are evaluated once more.
if dynamic_again && !spills.is_empty() {
dynamic_again = false;
spill_changes.extend(self.graph.dynamic.iter().copied());
}
// An area a formula no longer spills over may unblock another formula's array.
for (si, o) in freed {
for &(s, c) in self.graph.nodes.keys() {
if s == si
&& c.row <= o.end.row
&& c.col <= o.end.col
&& wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill))
{
spill_changes.push((s, c));
}
}
}
if full {
// Everything was already evaluated once; spill dependents need another pass.
}
@@ -488,6 +395,105 @@ impl Calc {
self.circular.retain(|k| self.graph.nodes.contains_key(k));
}
}
fn write_back(
&mut self,
wb: &mut Workbook,
dirty: &[Key],
results: HashMap<Key, Value>,
spills: &HashMap<Key, std::sync::Arc<Array>>,
) -> Vec<Key> {
let mut spill_changes = Vec::new();
let mut freed = Vec::new();
for (key, value) in results {
let Some(sheet) = wb.sheet_mut(key.0) else { continue };
if let Some(cell) = sheet.cells.get_mut(key.1)
&& cell.value != value
{
cell.value = value;
}
}
for key in dirty {
let new = spills.get(key);
let Some(sheet) = wb.sheet_mut(key.0) else { continue };
let old = sheet.spill_ranges.get(&key.1).copied();
if old.is_none() && new.is_none() {
continue;
}
// An unchanged spill does not alter any values its dependents read.
if let (Some(range), Some(array)) = (old, new)
&& spills_as_before(sheet, key.1, range, array)
{
continue;
}
if let Some(range) = old {
for cell in range.iter() {
sheet.spill.remove(&cell);
}
sheet.spill_ranges.remove(&key.1);
}
let mut changed_range = old;
if let Some(array) = new {
let end = CellRef::new(key.1.row.saturating_add(array.rows as u32 - 1), key.1.col.saturating_add(array.cols as u32 - 1));
let range = RangeRef::new(key.1, end);
let blocked = spill_is_blocked(sheet, key.1, range, end);
if blocked {
if let Some(cell) = sheet.cells.get_mut(key.1) {
cell.value = Value::Error(CellError::Spill);
}
let mut blocked_dependents = Vec::new();
self.graph.dependents_of_range(key.0, range, &mut blocked_dependents);
spill_changes.extend(blocked_dependents.into_iter().filter(|dependent| dependent != key));
} else {
fill_spill(sheet, key.1, range, array);
changed_range = Some(changed_range.map_or(range, |previous| previous.union(&range)));
}
}
if let Some(range) = changed_range {
let mut dependents = Vec::new();
self.graph.dependents_of_range(key.0, range, &mut dependents);
let freed_old_range = match (old, sheet.spill_ranges.get(&key.1)) {
(Some(previous), Some(current)) => !(current.contains(previous.start) && current.contains(previous.end)),
(_, None) => true,
(None, Some(_)) => false,
};
spill_changes.extend(dependents.into_iter().filter(|dependent| dependent != key || freed_old_range));
}
if let Some(previous) = old
&& !sheet.spill_ranges.get(&key.1).is_some_and(|current| current.contains(previous.start) && current.contains(previous.end))
{
freed.push((key.0, previous));
}
}
for (sheet_index, range) in freed {
for &(sheet, cell) in self.graph.nodes.keys() {
let was_blocked_cell = cell.row <= range.end.row
&& cell.col <= range.end.col
&& wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill));
if sheet == sheet_index && was_blocked_cell {
spill_changes.push((sheet, cell));
}
}
}
spill_changes
}
}
fn spill_is_blocked(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, end: CellRef) -> bool {
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| range.iter().any(|cell| cell != anchor && (sheet.cells.has(cell) || sheet.spill.contains_key(&cell)))
|| sheet.merges.iter().any(|merge| merge.intersects(&range))
|| sheet.tables.iter().any(|table| table.range.contains(anchor))
}
fn fill_spill(sheet: &mut gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, array: &Array) {
for (index, cell) in range.iter().enumerate() {
if cell != anchor {
sheet.spill.insert(cell, array.data.get(index).cloned().unwrap_or_default());
}
}
sheet.spill_ranges.insert(anchor, range);
}
/// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm).
@@ -513,21 +519,13 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
ev.value(expr)
}
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
fn prof_now() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
gridcraft_core::date::unix_now_ms().unwrap_or(0.0)
}
#[cfg(test)]
pub(crate) mod tests {
use std::time::{Duration, Instant};
use std::time::Duration;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
@@ -640,11 +638,10 @@ pub(crate) mod tests {
}
/// Opening (full recalc), changing every value of a column, and re-entering every formula.
#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm
fn open_and_bulk_edit(rows: u32) -> Duration {
let mut wb = sum_rows(rows);
let mut calc = Calc::new();
let t = Instant::now();
let started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0);
calc.recalc_all(&mut wb);
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
if let Some(sh) = wb.sheet_mut(0) {
@@ -655,7 +652,8 @@ pub(crate) mod tests {
calc.cells_changed(&mut wb, &values);
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
calc.cells_changed(&mut wb, &formulas);
let elapsed = t.elapsed();
let ended = gridcraft_core::date::unix_now_ms().unwrap_or(started);
let elapsed = Duration::from_secs_f64((ended - started).max(0.0) / 1000.0);
let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10)));
let r = rows - 1;
assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::<u32>()))));
@@ -670,7 +668,6 @@ pub(crate) mod tests {
#[test]
#[ignore = "timing-sensitive; run with --ignored in release"]
#[allow(clippy::disallowed_methods)]
fn recalc_with_range_formulas_scales_linearly() {
// Each row's SUM reads a range in the same columns. Recalculating on open used to walk
// every row per range read (the used range), and finding a cell's dependents scanned
+9 -2
View File
@@ -1,4 +1,8 @@
use super::*;
use super::RangeRef;
use crate::eval::Area;
use gridcraft_core::CellRef;
use std::collections::BTreeSet;
use std::hash::{BuildHasherDefault, Hasher};
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
#[derive(Default, Clone, Copy)]
@@ -179,6 +183,9 @@ pub struct Graph {
unsettled_wide: HashSet<usize>,
}
/// A formula's precedents split by shape: single cells, column intervals and wide ranges.
type Entries = (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>);
impl Graph {
pub fn len(&self) -> usize {
self.nodes.len()
@@ -189,7 +196,7 @@ impl Graph {
/// Index entries of a formula's precedents, without duplicates: single cells, column
/// intervals and wide ranges.
fn entries(areas: &[Area]) -> (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) {
fn entries(areas: &[Area]) -> Entries {
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
for a in areas {
if a.range.is_single() {
+3 -1
View File
@@ -1,5 +1,7 @@
use super::graph::{HashMap, HashSet, Key};
use super::*;
use super::{Evaluator, Host, Workbook, stack_position};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
+19
View File
@@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() {
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn lifted_lookups_stop_past_their_work_budget() {
// Security: each needle searched the whole lookup vector, so this one cell ran for hours
// (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now.
let mut t = T::new();
t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))");
assert_eq!(t.get("A1"), Value::Error(CellError::Num));
// Ordinary lifted lookups are unaffected.
for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() {
t.set(&format!("B{}", i + 1), &k.to_string());
t.set(&format!("C{}", i + 1), &(i + 1).to_string());
}
t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))");
t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))");
t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))");
t.set("D4", "=SUM(MATCH({25,50},B1:B5))");
assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
+3 -5
View File
@@ -1,9 +1,8 @@
//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
use std::collections::HashSet;
use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
@@ -70,8 +69,7 @@ impl Ctx<'_> {
}
String::new()
}
#[allow(clippy::too_many_arguments)]
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) {
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
if s.is_empty() {
return;
}
@@ -135,7 +133,7 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure)
if let Some(t) = chart_title(chart, &data) {
let size = TITLE_SIZE.min(h * 0.12).max(4.0);
let t = ctx.fit(&t, size, area.w);
ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top);
ctx.text(w / 2.0, area.y, &t, size, TEXT, (HAlign::Center, VAlign::Top));
let used = size * 1.3 + 4.0;
area.y += used;
area.h -= used;
+63 -42
View File
@@ -2,7 +2,10 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, plot_kind};
use super::lines::{decimate, draw_markers, draw_runs};
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
use super::*;
use super::{
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
VAlign, WHITE, clean, fixed_scale, nice_scale,
};
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
@@ -18,6 +21,9 @@ enum Fam {
Area100,
}
/// Stack accumulator: (axis, family) key with positive and negative running sums.
type Stack = ((bool, Fam), Vec<f64>, Vec<f64>);
fn family(k: ChartKind) -> Fam {
use ChartKind::*;
match k {
@@ -45,16 +51,7 @@ fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
for (i, s) in data.series.iter().enumerate() {
@@ -81,7 +78,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
if t > 0.0 { v / t } else { 0.0 }
};
// Stack accumulators keyed by (axis, family): positive and negative running sums.
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
let mut stacks: Vec<Stack> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
@@ -126,6 +123,10 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
spans.push(row);
}
spans
}
fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option<(f64, f64)>>]) -> Vec<(Scale, Fmt)> {
// Axis scales.
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
for axis in [false, true] {
@@ -151,6 +152,46 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
break;
}
}
axes
}
fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, f32) {
// Column slots: each clustered series gets one, each stack group shares one.
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
let mut slot_of = vec![0usize; fams.len()];
for (i, f) in fams.iter().enumerate() {
if !is_col(*f) {
continue;
}
let a = sec.get(i).copied().unwrap_or(false);
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
let k = match slot_keys.iter().position(|k| *k == key) {
Some(p) => p,
None => {
slot_keys.push(key);
slot_keys.len() - 1
}
};
if let Some(s) = slot_of.get_mut(i) {
*s = k;
}
}
(slot_of, slot_keys.len().max(1) as f32)
}
pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
let spans = series_spans(data, &fams, &sec, ncat);
let axes = axis_scales(data, &fams, &sec, &spans);
let Some((s1, f1)) = axes.first() else { return };
let y1 = ValAxis { scale: *s1, fmt: f1 };
let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f });
@@ -177,27 +218,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
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 };
// Column slots: each clustered series gets one, each stack group shares one.
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
let mut slot_of = vec![0usize; data.series.len()];
for (i, f) in fams.iter().enumerate() {
if !is_col(*f) {
continue;
}
let a = sec.get(i).copied().unwrap_or(false);
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
let k = match slot_keys.iter().position(|k| *k == key) {
Some(p) => p,
None => {
slot_keys.push(key);
slot_keys.len() - 1
}
};
if let Some(s) = slot_of.get_mut(i) {
*s = k;
}
}
let nslots = slot_keys.len().max(1) as f32;
let (slot_of, nslots) = column_slots(&fams, &sec);
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
@@ -275,20 +296,20 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 0.5) * bw;
if stacked {
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle);
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
} else if v >= 0.0 {
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
} else {
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
}
} else {
let x = c - nslots * bw / 2.0 + (k + 0.5) * bw;
if stacked {
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle);
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
} else if v >= 0.0 {
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
} else {
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top));
}
}
}
@@ -304,7 +325,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
if labels_on {
for (j, p) in pts.iter().enumerate() {
if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) {
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
@@ -312,7 +333,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
}
}
for (x, y, t) in area_labels.drain(..) {
ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
// Trendlines on top: x is the category number 1..n.
@@ -412,7 +433,7 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
if chart.data_labels && pts.len() <= MAX_LABELS {
for (p, raw) in px.iter().zip(pts) {
if let (Some(p), Some((_, y))) = (p, raw) {
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
+10 -10
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
#[derive(Clone, Copy, Debug)]
pub(super) struct Bx {
@@ -44,7 +44,7 @@ pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {
let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;
if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
area.h -= lh;
}
if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
@@ -123,12 +123,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
if grid {
ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);
}
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle);
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
if let (Some(a), Some(l)) = (y2, &y2labels) {
for (v, t) in l {
let yy = plot.bottom() - a.scale.frac(*v) * plot.h;
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle);
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
@@ -142,7 +142,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in l.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
// Outer levels, one row each below the labels: a group's name centred under its
// categories, and a separator at each group boundary down through the rows.
@@ -152,7 +152,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if first > 0 {
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
@@ -169,7 +169,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (v, t) in ticks.iter().zip(&labels).step_by(k) {
let xx = plot.x + s.frac(*v) * plot.w;
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if s.min <= 0.0 && s.max >= 0.0 {
let xx = plot.x + s.frac(0.0) * plot.w;
@@ -202,7 +202,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);
}
if i % k == 0 {
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
@@ -213,7 +213,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in labels.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
// Outer levels: a group's name centred beside its categories, separators at the
// boundaries running right to the axis.
@@ -224,7 +224,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, w - 4.0);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle));
}
if first > 0 {
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
+13 -10
View File
@@ -1,6 +1,9 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, point_color};
use super::*;
use super::{
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData,
TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- histogram
@@ -47,7 +50,7 @@ pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
let x = plot.x + b as f32 * cw;
ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75)));
if chart.data_labels {
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
@@ -110,7 +113,7 @@ pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
}
if chart.data_labels && ncat <= MAX_LABELS {
let v = if *kind == 0 { *t } else { t - b };
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
@@ -150,10 +153,10 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
if chart.data_labels {
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, (HAlign::Center, VAlign::Middle));
}
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
}
}
}
@@ -194,7 +197,7 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
a += sweep;
}
for (x, y, t, c) in labels {
ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle);
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
}
}
@@ -281,11 +284,11 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
}
}
}
@@ -325,7 +328,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
ctx.line(ring, GRID, 0.75);
}
if ti % every == 0 {
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle);
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, (HAlign::Right, VAlign::Middle));
}
}
let step = ncat.div_ceil(72).max(1);
@@ -349,7 +352,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
VAlign::Middle
};
let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0));
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign);
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, (align, valign));
}
for s in &data.series {
let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect();
+7 -3
View File
@@ -1,6 +1,10 @@
use super::frame::Bx;
use super::trend::supports_trendline;
use super::*;
use super::{
Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP,
VAlign, auto_name, series_color,
};
use gridcraft_model::LegendPos;
/// How a legend entry's swatch is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -132,7 +136,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
draw_swatch(ctx, e.2, x, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
x += iw + spacing;
}
@@ -152,7 +156,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
draw_swatch(ctx, e.2, x0, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
let used = colw + 8.0;
if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
+2 -1
View File
@@ -1,5 +1,6 @@
use super::frame::Bx;
use super::*;
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
use std::collections::HashSet;
// ---------------------------------------------------------------- helpers
+8 -4
View File
@@ -1,6 +1,9 @@
use super::frame::{Bx, value_range};
use super::lines::decimate;
use super::*;
use super::{
ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign,
fit_trendline, moving_average,
};
// ---------------------------------------------------------------- trendlines
@@ -36,10 +39,11 @@ pub(super) fn max_backward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
}
type ClippedSegment = ((f64, f64), (f64, f64), bool, bool);
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
/// end were cut.
#[allow(clippy::type_complexity)]
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<ClippedSegment> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
@@ -161,6 +165,6 @@ pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
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);
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top));
}
}
+24 -20
View File
@@ -295,15 +295,16 @@ mod tests {
use super::*;
#[test]
fn range_parse_rejects_signed_rows() {
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
assert!(RangeRef::parse("+1:+3").is_none());
assert!(RangeRef::parse("-1:3").is_none());
assert!(RangeRef::parse("$$1:3").is_none());
assert!(RangeRef::parse(":3").is_none());
assert!(RangeRef::parse("$$A:$$C").is_none());
assert!(RangeRef::parse("$A:$C").is_some());
let r = RangeRef::parse("$1:$3").unwrap();
let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?;
assert_eq!((r.start.row, r.end.row), (0, 2));
Ok(())
}
#[test]
@@ -389,18 +390,20 @@ mod tests {
}
#[test]
fn ranges() {
let r = RangeRef::parse("C3:A1").unwrap();
fn ranges() -> Result<(), std::io::Error> {
let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range"));
let r = parse("C3:A1")?;
assert_eq!(r.a1(), "A1:C3");
assert_eq!(r.count(), 9);
assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C");
assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5");
assert_eq!(parse("A:C")?.a1(), "A:C");
assert_eq!(parse("3:5")?.a1(), "3:5");
assert!(RangeRef::parse("0:5").is_none());
let a = RangeRef::parse("A1:C3").unwrap();
let b = RangeRef::parse("B2:D4").unwrap();
assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3");
let a = parse("A1:C3")?;
let b = parse("B2:D4")?;
assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3");
assert_eq!(a.union(&b).a1(), "A1:D4");
assert_eq!(a.iter().count(), 9);
Ok(())
}
#[test]
@@ -458,18 +461,19 @@ mod category_level_tests {
use super::*;
#[test]
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
let r = |s: &str| RangeRef::parse(s).unwrap();
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> {
let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range"));
assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 }));
assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped");
assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list");
// A row of values: levels are rows.
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None);
// Literal values: the categories' own shape decides.
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns");
Ok(())
}
}
+11 -11
View File
@@ -47,13 +47,11 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
}
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`.
#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm
/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap.
pub fn unix_now_ms() -> Option<f64> {
#[cfg(not(target_arch = "wasm32"))]
{
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?;
Some(d.as_secs_f64() * 1000.0)
Some(chrono::Utc::now().timestamp_millis() as f64)
}
#[cfg(target_arch = "wasm32")]
{
@@ -323,7 +321,7 @@ mod tests {
}
#[test]
fn fictitious_1900_02_29() {
fn fictitious_1900_02_29() -> Result<(), std::io::Error> {
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
// through it.
let s = DateSystem::D1900;
@@ -345,7 +343,7 @@ mod tests {
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
// Every serial up to 1900-12-31 comes back from its own year, month and day.
for serial in 0..=366 {
let d = datetime_from_serial(s, serial as f64).unwrap();
let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?;
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
}
assert_eq!(days_in_month_in(s, 1900, 2), 29);
@@ -358,6 +356,7 @@ mod tests {
assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
Ok(())
}
#[test]
@@ -371,17 +370,18 @@ mod tests {
}
#[test]
fn roundtrip() {
fn roundtrip() -> Result<(), std::io::Error> {
let s = DateSystem::D1900;
for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
let d = datetime_from_serial(s, serial).unwrap();
let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?;
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
}
let d = datetime_from_serial(s, 60.0).unwrap();
let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?;
assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
let d = datetime_from_serial(s, 46302.75).unwrap();
let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?;
assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday
let d = datetime_from_serial(s, 1.0).unwrap();
let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?;
assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
Ok(())
}
}
+3 -2
View File
@@ -454,11 +454,12 @@ mod tests {
}
#[test]
fn arrays() {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
fn arrays() -> Result<(), std::io::Error> {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).ok_or_else(|| std::io::Error::other("valid array"))?;
assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0)));
assert!(Array::new(2, 2, vec![]).is_none());
let row = Array::row(vec![1.0.into(), 2.0.into()]);
assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0));
Ok(())
}
}
+1
View File
@@ -17,6 +17,7 @@ gridcraft-model = { workspace = true }
gridcraft-calc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
web-time = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
gridcraft-xlsx = { workspace = true }
+1
View File
@@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("view.unhideWindow", "View", "Window", "Unhide"),
("view.switchWindows", "View", "Window", "Switch Windows"),
("view.macros", "View", "Macros", "Macros"),
("macro.record", "View", "Macros", "Record Macro"),
// Automate
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
+95 -77
View File
@@ -136,6 +136,95 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
pub use gridcraft_model::icon_count;
struct RuleEval<'a> {
wb: &'a Workbook,
sheet: usize,
cell: CellRef,
first: CellRef,
value: &'a Value,
text: &'a str,
stats: &'a RuleStats,
}
fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> {
let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx;
match rule {
CfRule::CellIs { op, a, b, style } => {
let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell));
let av = eval(a);
let matches = match op {
CfOperator::Between | CfOperator::NotBetween => {
let bv = b.as_deref().map(eval).unwrap_or_default();
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater;
if *op == CfOperator::Between { within } else { !within }
}
CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal,
CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal,
CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater,
CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less,
CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater,
};
(matches && !value.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
CfRule::ContainsText { text: needle, style } => {
(!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: needle, style } => {
(value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: needle, style } => {
(!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: needle, style } => {
(!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref())
}
CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => value.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
}
CfRule::Unique { style } => {
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
}
_ => None,
}
}
fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool {
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let day = serial.floor();
let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial);
let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0);
let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1));
let year_month = date(day).map(|value| (value.year, value.month));
let month_matches = |offset: i32| {
let shifted_month = month as i32 + offset;
let shifted_year = year + (shifted_month - 1).div_euclid(12);
let shifted_month = (shifted_month - 1).rem_euclid(12) + 1;
year_month == Some((shifted_year, shifted_month as u32))
};
match period {
"yesterday" => day == today - 1.0,
"today" => day == today,
"tomorrow" => day == today + 1.0,
"last7Days" => day > today - 7.0 && day <= today,
"lastWeek" => day >= week_start - 7.0 && day < week_start,
"thisWeek" => day >= week_start && day < week_start + 7.0,
"nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0,
"lastMonth" => month_matches(-1),
"thisMonth" => month_matches(0),
"nextMonth" => month_matches(1),
_ => false,
}
}
impl CfCache {
pub fn new() -> Self {
Self::default()
@@ -161,52 +250,8 @@ impl CfCache {
None => &no_stats,
};
let text = v.display();
let matched_style: Option<&Style> = match &cf.rule {
CfRule::CellIs { op, a, b, style } => {
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c));
let av = eval(a);
let ok = match op {
CfOperator::Between | CfOperator::NotBetween => {
let bv = b.as_deref().map(eval).unwrap_or_default();
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater;
if *op == CfOperator::Between { inside } else { !inside }
}
CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal,
CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal,
CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater,
CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less,
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
};
// As in Excel, a blank cell compares as 0 (or ""), and an error never matches.
(ok && !v.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
// As in Excel, an error value never contains, begins or ends with anything (its rule
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
CfRule::ContainsText { text: t, style } => {
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: t, style } => {
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: t, style } => {
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
}
CfRule::Unique { style } => {
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
}
let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st };
let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule {
CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() {
Some(n) if !st.sorted.is_empty() => {
let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize };
@@ -229,36 +274,8 @@ impl CfCache {
None => None,
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) => {
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
let d = n.floor();
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
let week_start = today - wd;
let (ty, tm) =
gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1));
let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month));
let month_off = |k: i32| {
let m = tm as i32 + k;
let y = ty + (m - 1).div_euclid(12);
let m = (m - 1).rem_euclid(12) + 1;
ym == Some((y, m as u32))
};
let ok = match period.as_str() {
"yesterday" => d == today - 1.0,
"today" => d == today,
"tomorrow" => d == today + 1.0,
"last7Days" => d > today - 7.0 && d <= today,
"lastWeek" => d >= week_start - 7.0 && d < week_start,
"thisWeek" => d >= week_start && d < week_start + 7.0,
"nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0,
"lastMonth" => month_off(-1),
"thisMonth" => month_off(0),
"nextMonth" => month_off(1),
_ => false,
};
ok.then_some(style.as_ref())
}
None => None,
Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
_ => None,
},
CfRule::ColorScale { stops } => {
if let Some(n) = v.as_f64() {
@@ -329,7 +346,8 @@ impl CfCache {
}
None
}
};
_ => None,
});
if let Some(st) = matched_style {
any = true;
// Every matching rule applies; where two set the same part, the rule with the
+40 -10
View File
@@ -12,6 +12,7 @@ pub mod format;
pub mod formulas;
pub mod insert;
pub mod inspect;
pub mod macros;
pub mod pivot;
pub mod print;
pub mod review;
@@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(inspect::specs());
v.extend(extra::specs());
v.extend(macros::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
@@ -205,7 +207,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
@@ -338,6 +339,9 @@ pub struct Ctx<'a> {
/// The command applied sheet protection itself (row/column inserts and deletes the
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
pub protection_checked: bool,
/// The structural edits the command made, in order (the sheet's name, the edit), as
/// references were rewritten for them.
pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>,
}
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
@@ -349,26 +353,48 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
})
}
fn wall_clock() -> std::time::Duration {
std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
#[allow(clippy::disallowed_methods)]
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
// `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch
// reads the wall clock without the (disallowed) `now`.
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let mut ctx = Ctx {
wb: (*d.wb).clone(),
changed: Vec::new(),
structural: false,
sel: &mut sel,
fit_rows: Vec::new(),
protection_checked: false,
edits: Vec::new(),
};
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if !structural {
check_arrays(&d.wb, &wb)?;
}
if let Some(log) = d.touched.as_mut() {
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
if let Some(sh) = wb.sheet(*si) {
log.push((sh.name.clone(), *c));
}
}
}
if let Some(log) = d.edits.as_mut() {
log.extend(edits);
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
}
if structural {
d.calc.recalc_all(&mut wb);
@@ -376,7 +402,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
d.calc.cells_changed(&mut wb, &changed);
}
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", t0.elapsed());
eprintln!("commit: + recalc {:?}", since(t0));
}
fit_rows.sort_unstable();
fit_rows.dedup();
@@ -384,13 +410,17 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
auto_row_height(&mut wb, si, row);
}
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", t0.elapsed());
eprintln!("commit: + rows {:?}", since(t0));
}
d.wb = std::sync::Arc::new(wb);
d.selection = sel;
Ok(r)
}
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
/// that kept a cell's value, so a shift is refused then.
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
/// Excel's message for an edit of a locked cell on a protected sheet.
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
+1 -1
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet};
use serde_json::{Value as Json, json};
use super::whatif::parse_range;
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param};
const CMD: &str = "data.analysisToolPak";
/// Most input cells a tool reads.
+8 -2
View File
@@ -4,10 +4,16 @@ use std::cmp::Ordering;
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value, sort_compare};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation,
ValidationKind, Workbook,
};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet,
str_param, target_range, target_ranges, target_sheet,
};
use crate::outline;
use crate::selection::current_region;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::sort::sort_key;
use super::*;
use super::{
AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param,
current_region, data_target, edit, json,
};
// ---------------------------------------------------------------- filter
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines,
str_param, target_sheet,
};
// ---------------------------------------------------------------- outline
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet,
Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet,
};
#[derive(Clone)]
struct SortKey {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json,
ok, str_param, target_range, target_sheet,
};
// ---------------------------------------------------------------- tools
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad,
bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- validation
+2 -2
View File
@@ -1,10 +1,10 @@
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
use gridcraft_core::CellRef;
use gridcraft_model::*;
use gridcraft_model::{Anchor, Color, Shape, ShapeKind};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
+25 -7
View File
@@ -4,7 +4,10 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc,
locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use crate::selection::{current_region, jump};
use crate::{Clipboard, Session};
@@ -210,6 +213,13 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- clear
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
}
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
/// are left as they are; rows hidden by hand are cleared.
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
@@ -220,7 +230,10 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
let skip = filtered_rows(sh, r);
sh.cells
.iter_range(r)
.any(|(c, x)| !skip.contains(&c.row) && x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
@@ -234,8 +247,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
},
None => continue,
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
.unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
@@ -270,16 +287,17 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
_ => {}
}
}
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !r.contains(*c));
sh.comments.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
sh.hyperlinks.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "formats") {
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m);
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
unmerged |= !keep;
keep
});
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json,
locked_cell_error, str_param, target_range, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- entry
+3 -1
View File
@@ -1,6 +1,8 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::*;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
+2 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
// ---------------------------------------------------------------- fill
@@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
let stop = f64_param(p, "stop");
let unit = str_param(p, "dateUnit").unwrap_or("day").to_string();
edit(s, |cx| {
crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?;
crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?;
Ok(Json::Null)
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
use super::cell::input_to_cell;
use super::*;
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
// ---------------------------------------------------------------- find & replace
+1 -1
View File
@@ -1,5 +1,5 @@
use super::selection::{select_region, selection_set};
use super::*;
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
/// How many references the Go To dialog keeps for going back to.
pub(super) const GO_TO_RECENT: usize = 10;
+202 -195
View File
@@ -1,5 +1,76 @@
use super::clipboard::What;
use super::*;
use super::{
Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param,
target_sheet,
};
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
}
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
let operand = new.value.clone();
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
&& (operand.is_number() || operand.is_empty())
{
new.value = match op {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0),
"divide" => Value::number(x / y),
_ => operand.clone(),
};
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
new.value = old.value.clone();
}
if let Some(formula) = &old.formula
&& let Ok(y) = operand.to_number()
&& (operand.is_number() || operand.is_empty())
&& let Some(symbol) = operation_symbol(op)
{
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
fn operation_symbol(op: &str) -> Option<char> {
match op {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
}
fn copy_merges(
cx: &mut crate::cmd::Ctx,
dest_sheet: usize,
src_sheet: &gridcraft_model::Sheet,
src: RangeRef,
at: CellRef,
what: What,
transpose: bool,
) -> Result<()> {
if !matches!(what, What::All | What::Formats) || transpose {
return Ok(());
}
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
for merge in merges {
let row_offset = at.row as i64 - src.start.row as i64;
let col_offset = at.col as i64 - src.start.col as i64;
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
let moved = RangeRef::new(start, end);
let sheet = cx.sheet_mut(dest_sheet)?;
sheet.merges.retain(|existing| !existing.intersects(&moved));
sheet.merges.push(moved);
}
}
Ok(())
}
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
@@ -74,209 +145,145 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
moved_formulas = taken;
}
for tr in 0..tiles_r {
for tc in 0..tiles_c {
for r in 0..src.height() {
for c in 0..src.width() {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move {
moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone())
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
src_sheet.cell(sc).cloned()
st
};
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
st
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
let a = old.value.clone();
let b = new.value.clone();
// Numbers stored as text take part (multiplying by 1 turns them into
// numbers); other text and errors stay as they are.
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
&& (a.is_number() || a.is_empty() || a.is_text())
&& (b.is_number() || b.is_empty())
{
new.value = match op.as_str() {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" => {
if y == 0.0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(x / y)
}
}
_ => b.clone(),
};
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
new.value = a;
}
// A formula already there is kept and combined with the pasted number.
if let Some(f) = &old.formula
&& let Ok(y) = b.to_number()
&& (b.is_number() || b.is_empty())
&& let Some(sym) = match op.as_str() {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
{
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
// Numbers stored as text participate; other text and errors stay as they are.
apply_operation(&op, &old, &mut new);
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
// Merged areas inside the copied block travel with it.
if matches!(what, What::All | What::Formats) && !transpose {
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
for m in merges {
let dr = at.row as i64 - src.start.row as i64;
let dc = at.col as i64 - src.start.col as i64;
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
let nm = RangeRef::new(a, b);
let sh = cx.sheet_mut(dest_sheet)?;
sh.merges.retain(|x| !x.intersects(&nm));
sh.merges.push(nm);
}
}
}
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
target_sheet,
};
// ---------------------------------------------------------------- selection
+8 -2
View File
@@ -2,10 +2,16 @@
//! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
Workbook,
};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc,
ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
mod cells;
mod chart;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok,
str_param, target_ranges, target_sheet,
};
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit,
f64_param, json, split_sheet, str_param, u32_param,
};
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
+3 -1
View File
@@ -1,5 +1,7 @@
use super::chart::format_series;
use super::*;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
};
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
+42 -7
View File
@@ -1,10 +1,15 @@
use super::*;
use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
let cmds: Vec<Json> =
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect();
let cmds: Vec<Json> = s
.journal
.iter()
.skip(start)
.filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str()))
.map(|(id, p)| json!({"command": id, "params": p}))
.collect();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
@@ -104,12 +109,16 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
// What ran, for a recorder in progress: the recording keeps these steps with the run, so
// editing or deleting the script before the recording stops does not change what it saved.
let mut ran_steps: Vec<(String, Json)> = Vec::new();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
let before = s.ui_requests.len();
let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone()));
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
@@ -120,17 +129,43 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
let result = ran?;
n += 1;
if s.recorder.is_some() {
// The recorder's rule for each step, as for a command run on its own: a menu-style
// step that only opened a dialog is left out (playing it back would stop at the
// dialog), and a toggle keeps the state it set.
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
if !opened_dialog || changed {
ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result)));
}
}
}
if s.recorder.is_some() {
s.script_ran = Some(ran_steps);
}
Ok(json!({"ran": n}))
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
/// Redo work there again. A macro being debugged on that workbook is stopped with them.
pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
// Inside another command the open groups are that command's own (an action in progress):
// a step of it that stopped them would pull the workbook from under the rest of its run.
if s.depth() > 1 {
return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into()));
}
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
let mut out = json!({"stopped": s.abort_groups(uid)});
// A macro being debugged here had its group among them: its run is over.
if let Some((name, wrote_files)) = crate::macros::debug::end_stopped(s, uid) {
out["macro"] = json!(name);
out["message"] = json!(format!(
"Macro '{name}', which was being debugged, was stopped and rolled back. {}",
crate::macros::run::rolled_back(&wrote_files)
));
}
Ok(out)
}
pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet,
stash_get, stash_map, stash_set, str_param, target_sheet, u32_param,
};
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
+7 -3
View File
@@ -2,7 +2,7 @@
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
crate::io::csv_values_bytes(&wb)?
} else {
crate::io::save_bytes(&wb, &target)?
};
crate::io::write_file(&target, &bytes)?;
Ok(json!({"path": target, "bytes": bytes.len()}))
}
+1 -1
View File
@@ -11,7 +11,7 @@ use gridcraft_functions::Timeline;
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
use crate::selection::current_region;
const CMD: &str = "data.forecastSheet";
+5 -2
View File
@@ -1,10 +1,13 @@
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param,
target_ranges, target_sheet,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
};
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
};
// ---------------------------------------------------------------- sizes
+4 -1
View File
@@ -4,7 +4,10 @@ use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, stash, str_param,
target_range, target_ranges, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
};
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
};
// ---------------------------------------------------------------- conditional formatting
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
split_sheet, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- links & comments
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
};
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
+5 -2
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
};
// ---------------------------------------------------------------- tables
@@ -57,7 +60,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
+1 -1
View File
@@ -3,7 +3,7 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param_on, cmd, has_doc, str_param, target_range, target_sheet};
pub fn specs() -> Vec<CommandSpec> {
vec![
+372
View File
@@ -0,0 +1,372 @@
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate,
//! older feature with their own storage; these commands neither read nor change it. A recording
//! keeps the steps a script run ran while it recorded, as the run reported them.
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param};
use crate::macros::recorder::RecordingStart;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list),
cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get),
cmd!(
"macro.save",
"Save Macro",
[],
None,
"{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default false: saving over a macro with the name is refused)} → {name, steps}",
has_doc,
save
),
cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete),
cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename),
cmd!(
"macro.run",
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
has_doc,
run
),
cmd!(
query "macro.check",
"Check Macro",
[],
None,
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
has_doc,
check
),
cmd!(
noundo "macro.record",
"Record Macro",
["View", "Macros"],
None,
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
has_doc,
record
),
cmd!(
"macro.debug",
"Step Into",
[],
None,
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
has_doc,
|s, p| crate::macros::debug::debug(s, p)
),
]
}
fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
}
/// The index of macro `name` in the active workbook, or an error that says there is none.
fn index_of(s: &Session, name: &str) -> Result<usize> {
crate::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
Ok(Json::Array(
wb.macros
.iter()
.map(|m| {
let mut o = json!({
"name": m.name,
"description": m.description,
"steps": m.step_count(),
"inputs": m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>(),
"onConflict": m.output.on_conflict.name(),
"permissions": m.permissions,
});
if let Some(k) = &m.shortcut {
o["shortcut"] = json!(k);
}
o
})
.collect(),
))
}
fn get(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.get", p)?)?;
let wb = &s.doc()?.wb;
Ok(wb.macros.get(i).map(crate::macros::macro_json).unwrap_or(Json::Null))
}
fn save(s: &mut Session, p: &Json) -> Result<Json> {
let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p);
let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?;
// Replacing is asked for, never assumed: a save with a name in use is refused.
let replace = match p.get("replace") {
None | Some(Json::Null) => false,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("macro.save", "`replace` must be true or false")),
};
let (name, steps) = (m.name.clone(), m.step_count());
edit(s, |cx| {
if let Some(kept) = crate::macros::kept_name(&cx.wb, &m.name) {
return Err(EngineError::Other(taken_by_kept(&kept)));
}
match crate::macros::find(&cx.wb, &m.name) {
Some(i) if !replace => {
let existing = cx.wb.macros.get(i).map_or(m.name.as_str(), |o| o.name.as_str());
return Err(EngineError::Other(format!("A macro called '{existing}' already exists. Pass `replace: true` to replace it.")));
}
Some(i) => {
if let Some(old) = cx.wb.macros.get_mut(i) {
*old = m;
}
}
None => {
crate::macros::check_room(&cx.wb).map_err(EngineError::Other)?;
cx.wb.macros.push(m);
}
}
Ok(())
})?;
Ok(json!({"name": name, "steps": steps}))
}
/// The refusal of a name that a macro kept in a newer format has.
fn taken_by_kept(name: &str) -> String {
format!(
"A macro called '{name}' made by a newer version of GridCraft is kept in this workbook; this version can't change it. Choose another name."
)
}
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.delete", p)?)?;
edit(s, |cx| {
if i < cx.wb.macros.len() {
cx.wb.macros.remove(i);
}
Ok(Json::Null)
})
}
fn rename(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.rename", p)?)?;
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string();
if to.chars().any(char::is_control) {
return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters"));
}
let wb = &s.doc()?.wb;
if crate::macros::find(wb, &to).is_some_and(|j| j != i) {
return Err(EngineError::Other(format!("A macro called '{to}' already exists.")));
}
if let Some(kept) = crate::macros::kept_name(wb, &to) {
return Err(EngineError::Other(taken_by_kept(&kept)));
}
edit(s, |cx| {
let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) };
let mut renamed = m.clone();
renamed.name = to.clone();
// The whole check, size included: a longer name can take a macro past what a file holds.
crate::macros::check(&renamed).map_err(EngineError::Other)?;
*m = renamed;
Ok(())
})?;
Ok(json!({"name": to}))
}
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.run", p)?;
// While recording: the steps a recording keeps for this run, taken from the macro as it runs,
// so saving or deleting the macro before the recording stops doesn't change what it saved.
let inline = if s.is_recording() {
let wb = &s.doc()?.wb;
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::macros::recorder::inline_steps)
} else {
None
};
let out = crate::macros::run::run(s, name, p)?;
if s.is_recording() {
// `None` when the macro's steps don't stand on their own: the recording leaves the run out.
s.script_ran = inline;
}
Ok(out)
}
fn check(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.check", p)?;
crate::macros::run::check(s, name, p)
}
fn record(s: &mut Session, p: &Json) -> Result<Json> {
check_record_params(s.is_recording(), p)?;
// `cancel` ends a recording without saving it: the way out of a recording whose macro
// cannot be saved (a name the check refuses, a full workbook).
if bool_param(p, "cancel").unwrap_or(false) {
let was = s.stop_recording().is_some();
s.macro_recording = None;
return Ok(json!({"recording": false, "cancelled": was}));
}
if bool_param(p, "stop").unwrap_or(false) || s.is_recording() {
return stop_recording(s, p);
}
start_recording(s, p, bool_param(p, "relative").unwrap_or(true))
}
/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and
/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while
/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong
/// kind. `name` and `description` count when starting and when stopping (stopping wins).
fn check_record_params(recording: bool, p: &Json) -> Result<()> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
for k in ["stop", "cancel", "relative"] {
if given(k).is_some_and(|v| !v.is_boolean()) {
return Err(bad("macro.record", format!("`{k}` must be true or false")));
}
}
for k in ["name", "description", "shortcut"] {
if given(k).is_some_and(|v| !v.is_string()) {
return Err(bad("macro.record", format!("`{k}` must be text")));
}
}
if given("cancel").and_then(Json::as_bool) == Some(true) {
if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) {
return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored")));
}
return Ok(());
}
let stop = given("stop").and_then(Json::as_bool);
if recording && stop == Some(false) {
return Err(bad(
"macro.record",
"a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved",
));
}
if (recording || stop == Some(true))
&& let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some())
{
return Err(bad("macro.record", format!("`{k}` is chosen when recording starts")));
}
Ok(())
}
/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are
/// checked now, so a bad name or a taken shortcut is refused before anything is recorded.
fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?),
None => None,
};
let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) {
Some(k) => {
let letter = crate::macros::parse_shortcut(k)
.ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?;
if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) {
return Err(bad("macro.record", why));
}
Some(crate::macros::shortcut_text(letter))
}
None => None,
};
let description = match str_param(p, "description") {
Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => {
return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT)));
}
Some(t) => Some(t.to_string()),
None => None,
};
let mut start = RecordingStart::new(d, relative);
(start.name, start.shortcut, start.description) = (name.clone(), shortcut, description);
let input = start.selection.current().a1();
s.start_recording();
s.macro_recording = Some(start);
let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) };
if let Some(n) = name {
out["name"] = json!(n);
}
Ok(out)
}
/// Stops recording and saves what was recorded as a macro, in the workbook where recording
/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the
/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed,
/// the recording ends unsaved.
fn stop_recording(s: &mut Session, p: &Json) -> Result<Json> {
let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) };
let start = s.macro_recording.take();
let at = match &start {
Some(st) => s
.docs
.iter()
.position(|d| d.uid == st.doc)
.ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?,
None => s.active_index(),
};
let saved = save_recording(s, p, &rec, start.clone(), at);
if saved.is_err() {
s.recorder = Some(rec);
s.macro_recording = start;
}
saved
}
/// Saves the recording into the workbook at index `at`.
fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option<RecordingStart>, at: usize) -> Result<Json> {
let d = s.docs.get(at).ok_or(EngineError::NoDocument)?;
// A macro being debugged there could still roll the saved macro back with its run.
if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) {
return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into()));
}
// A recording started without `macro.record` (`Session::start_recording`) takes its example
// input from the workbook as it is now.
let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
let (doc, wb) = (d.uid, d.wb.clone());
// A name given when stopping wins over the one given when starting; either one, if another
// macro has it, gets a suffix, so the save never replaces a macro.
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) {
Some(n) => crate::macros::unique_name(&wb, n),
None => crate::macros::next_name(&wb),
};
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran);
// Steps past the macro limit or its size, and ones recorded in another workbook, are left
// out and counted with the ones the recorder dropped.
let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS);
if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) {
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
}
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
// The shortcut, unless another macro took it while recording; then it is left off and the
// result says so.
let mut shortcut_dropped = None;
if let Some(k) = &start.shortcut {
if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) {
converted.mac.shortcut = Some(k.clone());
} else {
shortcut_dropped = Some(k.clone());
}
}
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
// Saved into that workbook even when another one is active now.
let active = s.active_index();
s.set_active(at);
let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false}));
s.set_active(active);
saved?;
// The person recorded it, so it runs without the approval a macro that came with a file
// needs, until it changes.
if let Some(d) = s.docs.iter().find(|d| d.uid == doc)
&& let Some(m) = crate::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i))
{
let key = (doc, crate::macros::fingerprint(m));
s.macro_approvals.insert(key);
}
let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
if let Some(k) = shortcut_dropped {
out["shortcutDropped"] = json!(k);
}
Ok(out)
}
+8 -2
View File
@@ -7,10 +7,16 @@
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, Cell, Chart, ChartKind, LegendPos, NumFmt, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Sheet, StyleId, Workbook,
};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, Ctx, EngineError, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, str_param,
u32_param,
};
use crate::pivot as pv;
use crate::selection::current_region;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{insert_pivot, source_param};
use super::*;
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, PivotDateGroup, Result, Session, Workbook, anchor_mode_param, bad, cell_param,
edit, f64_param, find_pivot, json, other, pv, str_param,
};
// ---------------------------------------------------------------- PivotChart
+13 -4
View File
@@ -1,6 +1,9 @@
use super::fields::{add_to_area, in_axes};
use super::fields::{AreaPlacement, add_to_area, in_axes};
use super::params::{func_name, func_param, group_name, group_param, layout_name, show_as_name, sort_name};
use super::*;
use super::{
Arc, Cell, CellRef, Ctx, EngineError, Json, NumFmt, PivotDateGroup, PivotTable, RangeRef, Result, Session, Sheet, StyleId, Workbook, bad,
bool_param, cell_param, current_region, edit, find_pivot, json, other, pv, str_param, with,
};
// ---------------------------------------------------------------- insert
@@ -115,7 +118,9 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> {
for f in list {
match f {
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
Json::String(n) => {
add_to_area(pt, &src, AreaPlacement { cmd: "insert.pivotTable", field: n, area, func: None, name: None, position: None })?
}
Json::Object(_) => {
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
let func = match str_param(f, "func") {
@@ -124,7 +129,11 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
}
None => None,
};
add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
add_to_area(
pt,
&src,
AreaPlacement { cmd: "insert.pivotTable", field: n, area, func, name: str_param(f, "name"), position: None },
)?;
let by = group_param(str_param(f, "group"));
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
&& matches!(area, "rows" | "columns")
+18 -14
View File
@@ -1,6 +1,9 @@
use super::create::pivot_json;
use super::params::{area_param, func_param, position_param, show_as_param};
use super::*;
use super::{
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, Workbook, bad,
bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
};
// ---------------------------------------------------------------- layout editing (pure)
@@ -104,16 +107,17 @@ fn auto_name(f: PivotFunc, field: &str) -> String {
}
/// Adds `field` to an area (moving it out of the other axis areas).
pub(super) fn add_to_area(
cmd: &str,
pt: &mut PivotTable,
src: &pv::Source,
field: &str,
area: &str,
func: Option<PivotFunc>,
name: Option<&str>,
position: Option<usize>,
) -> Result<()> {
pub(super) struct AreaPlacement<'a> {
pub cmd: &'a str,
pub field: &'a str,
pub area: &'a str,
pub func: Option<PivotFunc>,
pub name: Option<&'a str>,
pub position: Option<usize>,
}
pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: AreaPlacement<'_>) -> Result<()> {
let AreaPlacement { cmd, field, area, func, name, position } = placement;
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
if is_calc && area != "values" {
return Err(other("A calculated field can only be placed in the Values area."));
@@ -300,7 +304,7 @@ pub(super) fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").map(str::to_string);
let pos = position_param(p);
update(s, p, |pt, src, _| {
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &field, area, func, name: name.as_deref(), position: pos })?;
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
})
}
@@ -364,7 +368,7 @@ pub(super) fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
}
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &canon, area: "values", func: Some(PivotFunc::Sum), name: None, position: None })?;
}
Ok(json!({
"name": canon,
@@ -452,7 +456,7 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let src_field = remove_from_area(C, pt, &field, from)?;
add_to_area(C, pt, src, &src_field, to, None, None, pos)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &src_field, area: to, func: None, name: None, position: pos })?;
if matches!(to, "rows" | "columns") {
let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns };
if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) {
+4 -1
View File
@@ -1,7 +1,10 @@
use super::create::source_param;
use super::fields::{canonical, in_axes, shift_values, update};
use super::params::{group_param, layout_name};
use super::*;
use super::{
Chart, Json, PivotDateGroup, PivotField, PivotFilter, PivotLayout, PivotSort, PivotTable, Result, Session, bad, bool_param, edit, find_pivot,
json, other, pv, str_param,
};
pub(super) fn filter(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.filter";
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Json, PivotDateGroup, PivotFunc, PivotLayout, PivotShowAs, PivotSort, Result, bad, u32_param};
pub(super) fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
match v.map(str::to_ascii_lowercase) {
+121 -97
View File
@@ -243,6 +243,70 @@ fn compact_vs_tabular_shapes() {
assert_eq!(text(&s, "A5"), "Apples");
}
fn assert_values_on_rows_default(s: &Session) {
let p = pt(s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(s, "A4"), "East");
assert_eq!(val(s, "B4"), Value::Empty);
assert_eq!(text(s, "A5"), "Sum of Sales");
assert_eq!(val(s, "B5"), Value::Number(415.0));
assert_eq!(text(s, "A6"), "Sum of Units");
assert_eq!(val(s, "B6"), Value::Number(33.0));
assert_eq!(text(s, "A13"), "Total Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(TOTAL));
assert_eq!(text(s, "A14"), "Total Sum of Units");
assert_eq!(val(s, "B14"), Value::Number(72.0));
}
fn assert_values_on_rows_tabular(s: &mut Session) {
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(s, "A3"), "Region");
assert_eq!(text(s, "B3"), "Values");
assert_eq!(text(s, "C3"), "Total");
}
fn assert_values_on_rows_position(s: &mut Session) {
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(s).values_position, Some(0));
assert_eq!(text(s, "A4"), "Sum of Sales");
assert_eq!(val(s, "B4"), Value::Empty);
assert_eq!(text(s, "A5"), "East");
assert_eq!(val(s, "B5"), Value::Number(415.0));
assert_eq!(text(s, "A8"), "Sum of Units");
assert_eq!(text(s, "A9"), "East");
assert_eq!(val(s, "B9"), Value::Number(33.0));
assert_eq!(text(s, "A12"), "Total Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(s, "A4"), "Sum of Sales");
assert_eq!(val(s, "B4"), Value::Number(TOTAL));
assert_eq!(text(s, "A5"), "Sum of Units");
assert_eq!(val(s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(s).values_on_rows);
assert_eq!(text(s, "A3"), "Sum of Sales");
assert_eq!(text(s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
fn values_on_rows_layouts() {
let mut s = sales();
@@ -250,61 +314,9 @@ fn values_on_rows_layouts() {
assert_eq!(text(&s, "C3"), "Sum of Units");
// Σ Values after Region: each region holds one line per value field.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
let p = pt(&s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(&s, "A4"), "East");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A6"), "Sum of Units");
assert_eq!(val(&s, "B6"), Value::Number(33.0));
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
assert_eq!(text(&s, "A14"), "Total Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(72.0));
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Total");
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "East");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A8"), "Sum of Units");
assert_eq!(text(&s, "A9"), "East");
assert_eq!(val(&s, "B9"), Value::Number(33.0));
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(&s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
assert_eq!(text(&s, "A5"), "Sum of Units");
assert_eq!(val(&s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(&s).values_on_rows);
assert_eq!(text(&s, "A3"), "Sum of Sales");
assert_eq!(text(&s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
assert_values_on_rows_default(&s);
assert_values_on_rows_tabular(&mut s);
assert_values_on_rows_position(&mut s);
}
#[test]
@@ -355,53 +367,65 @@ fn change_source_keeps_values_ahead_of_its_field() {
assert_eq!(text(&s, "A5"), "Apples");
}
fn assert_values_between_compact_rows(s: &Session) {
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(s, "A4"), "East");
assert_eq!(text(s, "A5"), "Sum of Sales");
assert_eq!(text(s, "A6"), "Apples");
assert_eq!(val(s, "B6"), Value::Number(160.0));
assert_eq!(text(s, "A9"), "Sum of Units");
assert_eq!(text(s, "A10"), "Apples");
assert_eq!(val(s, "B10"), Value::Number(16.0));
assert_eq!(text(s, "A13"), "East Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(415.0));
assert_eq!(text(s, "A14"), "East Sum of Units");
assert_eq!(val(s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
}
fn assert_values_between_outline(s: &mut Session) {
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(s).last_range.unwrap().width(), 4);
assert_eq!(text(s, "A3"), "Region");
assert_eq!(text(s, "B3"), "Values");
assert_eq!(text(s, "C3"), "Product");
assert_eq!(text(s, "B5"), "Sum of Sales");
assert_eq!(text(s, "C6"), "Apples");
assert_eq!(val(s, "D6"), Value::Number(160.0));
}
fn assert_values_between_tabular(s: &mut Session) {
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(s, "A4"), "East");
assert_eq!(text(s, "B4"), "Sum of Sales");
assert_eq!(text(s, "C4"), "Apples");
assert_eq!(val(s, "D4"), Value::Number(160.0));
assert_eq!(text(s, "B7"), "Sum of Units");
assert_eq!(text(s, "C7"), "Apples");
assert_eq!(val(s, "D7"), Value::Number(16.0));
assert_eq!(text(s, "A10"), "East Sum of Sales");
assert_eq!(val(s, "D10"), Value::Number(415.0));
assert_eq!(text(s, "A11"), "East Sum of Units");
let r = pt(s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
fn values_on_rows_between_row_fields() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(text(&s, "A6"), "Apples");
assert_eq!(val(&s, "B6"), Value::Number(160.0));
assert_eq!(text(&s, "A9"), "Sum of Units");
assert_eq!(text(&s, "A10"), "Apples");
assert_eq!(val(&s, "B10"), Value::Number(16.0));
assert_eq!(text(&s, "A13"), "East Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(415.0));
assert_eq!(text(&s, "A14"), "East Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Product");
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C6"), "Apples");
assert_eq!(val(&s, "D6"), Value::Number(160.0));
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "B4"), "Sum of Sales");
assert_eq!(text(&s, "C4"), "Apples");
assert_eq!(val(&s, "D4"), Value::Number(160.0));
assert_eq!(text(&s, "B7"), "Sum of Units");
assert_eq!(text(&s, "C7"), "Apples");
assert_eq!(val(&s, "D7"), Value::Number(16.0));
assert_eq!(text(&s, "A10"), "East Sum of Sales");
assert_eq!(val(&s, "D10"), Value::Number(415.0));
assert_eq!(text(&s, "A11"), "East Sum of Units");
let r = pt(&s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
assert_values_between_compact_rows(&s);
assert_values_between_outline(&mut s);
assert_values_between_tabular(&mut s);
}
#[test]
+14 -6
View File
@@ -10,11 +10,16 @@
//! codes.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::*;
use gridcraft_pdf::{Font, Page, PdfDoc, Rgb};
#[cfg(test)]
use gridcraft_model::{Alignment, Image, SheetViewMode};
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
Underline, VAlign, Visibility, Workbook,
};
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param};
mod draw;
mod header_footer;
@@ -27,6 +32,9 @@ use header_footer::draw_header_footer;
pub use header_footer::{
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
};
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
#[cfg(test)]
use layout::bands;
pub(crate) use layout::split_bands;
@@ -94,7 +102,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut printed_blocks = Vec::new();
// Decoded pictures are reused across the pages their anchor covers: a floating picture
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
let mut pics = PicCache::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -116,7 +124,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
for (rsi, rs) in rsegs.iter().enumerate() {
let mut x = ox + lay.heading_w * k;
for (csi, cs) in csegs.iter().enumerate() {
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
let b = make_block(wb, lay.sheet, sh, *rs, *cs, (x, y), lay);
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
draw_block_links(pg, &b, page_index, &mut pending_links);
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
@@ -155,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
&& let Some(page) = doc.page_mut(link.page)
{
let (lx, ly, w, h) = link.rect;
page.link_page(lx, ly, w, h, target_page, x, y);
page.link_page(Rect::new(lx, ly, w, h), target_page, Point::new(x, y));
}
}
let n = doc.page_count();
+18 -14
View File
@@ -1,7 +1,10 @@
use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::draw_text;
use super::*;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
};
// ---------------------------------------------------------------- drawing
@@ -151,7 +154,7 @@ pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf)
};
let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) };
let (ax, ay) = xf.p(*x, *y);
pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation);
pg.text_rotated(Point::new(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs), size, font, blend(*color), text, *rotation);
}
}
}
@@ -207,10 +210,11 @@ fn border_dash(style: BorderStyle, k: f32) -> Vec<f32> {
}
}
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) {
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end: Point, k: f32) {
if b.is_none() {
return;
}
let (x0, y0, x1, y1) = (start.x, start.y, end.x, end.y);
let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]);
if b.style == BorderStyle::Double {
let off = 1.0 * k;
@@ -221,7 +225,7 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x
return;
}
let lw = (b.style.width() * k).clamp(0.25, 6.0);
pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k));
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
}
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
@@ -324,12 +328,12 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
),
None => (bd.right, bd.bottom),
};
draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k);
draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k);
draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k);
draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k);
draw_border(pg, &bd.top, b.wb, Point::new(x, y), Point::new(x + w, y), b.k);
draw_border(pg, &bottom, b.wb, Point::new(x, y + h), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.left, b.wb, Point::new(x, y), Point::new(x, y + h), b.k);
draw_border(pg, &right, b.wb, Point::new(x + w, y), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.diag_down, b.wb, Point::new(x, y), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.diag_up, b.wb, Point::new(x, y + h), Point::new(x + w, y), b.k);
}
// Text.
for (c, l) in &looks {
@@ -340,7 +344,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else {
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
}
}
pg.restore();
@@ -444,10 +448,10 @@ pub(super) fn make_block<'a>(
sh: &'a Sheet,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
origin: (f32, f32),
lay: &SheetLayout,
) -> Block<'a> {
let (x, y) = origin;
let k = lay.k();
let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize);
let mut acc = x;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::SheetLayout;
use super::*;
use super::{Font, Page, Value, Workbook};
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
pub(super) const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
+8 -2
View File
@@ -1,5 +1,8 @@
use super::header_footer::HF_FIXED_SIDE_MARGIN;
use super::*;
use super::{
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
bool_param, split_sheet, str_param,
};
/// Cells in a page block above which only stored cells are visited (not every grid position).
pub(super) const DENSE_BLOCK: u64 = 60_000;
@@ -287,10 +290,13 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(lay)
}
/// Inclusive-start/end index spans of the pages along one axis.
pub(crate) type Bands = Vec<(u32, u32)>;
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Bands, Bands) {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
+4 -11
View File
@@ -1,6 +1,6 @@
use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of};
use super::text::wrap_lines;
use super::*;
use super::{Anchor, Font, Page, RangeRef, Rect, ShapeKind, Sheet, Value, Workbook};
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
@@ -25,7 +25,7 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
let fit = (w / iw as f32).min(h / ih as f32);
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
pg.image_rgba(Rect::new(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph), image.as_raw(), [image.width(), image.height()]);
}
/// Sheet pixel rectangle of an anchor.
@@ -49,14 +49,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
/// picture again, but the decode happens once).
pub(super) fn draw_objects(
pg: &mut Page,
wb: &Workbook,
si: usize,
sh: &Sheet,
b: &Block,
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
) {
pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, pics: &mut super::PicCache) {
let block = b.range();
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
@@ -77,7 +70,7 @@ pub(super) fn draw_objects(
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(x, y, w, h, px, *pw, *ph);
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
}
}
for shp in &sh.shapes {
+59 -52
View File
@@ -1,5 +1,5 @@
use super::draw::{Block, Look, rgb_of};
use super::*;
use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet, Style, StyleTable, Underline, VAlign, Value};
const PAD_PX: f32 = 2.0;
@@ -51,11 +51,55 @@ fn font_for(style: &Style) -> Font {
Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic)
}
fn text_align(align: HAlign, value: &Value) -> HAlign {
match align {
HAlign::General => match value {
Value::Number(_) => HAlign::Right,
Value::Bool(_) | Value::Error(_) => HAlign::Center,
_ => HAlign::Left,
},
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
}
}
/// Is a cell free for text to overflow into?
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
}
fn extend_text_bounds(sh: &Sheet, c: CellRef, merges: &[RangeRef], k: f32, need: f32, align: HAlign, bounds: (f32, f32)) -> (f32, f32) {
let (mut clip_l, mut clip_r) = bounds;
let grow = |dir: i64, mut remaining: f32| -> f32 {
let mut ext = 0.0;
let mut col = c.col as i64 + dir;
while remaining > 0.0 && col >= 0 && col < MAX_COLS as i64 {
let nc = CellRef::new(c.row, col as u32);
if !empty_for_overflow(sh, nc, merges) {
break;
}
let width = sh.col_width(nc.col) * k;
ext += width;
remaining -= width;
col += dir;
if ext > 20_000.0 {
break;
}
}
ext
};
match align {
HAlign::Right => clip_l -= grow(-1, need),
HAlign::Center => {
clip_l -= grow(-1, need / 2.0);
clip_r += grow(1, need / 2.0);
}
_ => clip_r += grow(1, need),
}
(clip_l, clip_r)
}
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
let mut ext = 0.0;
@@ -71,16 +115,17 @@ pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, m
ext
}
pub(super) fn draw_text(
pg: &mut Page,
b: &Block,
c: CellRef,
l: &Look,
rect: (f32, f32, f32, f32),
merged: bool,
merges: &[RangeRef],
limits: (f32, f32),
) {
pub(super) struct TextPlacement<'a> {
pub cell: CellRef,
pub look: &'a Look,
pub rect: (f32, f32, f32, f32),
pub merged: bool,
pub merges: &'a [RangeRef],
pub limits: (f32, f32),
}
pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) {
let TextPlacement { cell: c, look: l, rect, merged, merges, limits } = placement;
let (x, y, w, h) = rect;
let sh = b.sh;
let wb = b.wb;
@@ -133,20 +178,7 @@ pub(super) fn draw_text(
text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255));
tw = gridcraft_pdf::text_width(font, size, &text);
}
let h_align = match st.align.h {
HAlign::General => {
if is_num {
HAlign::Right
} else if matches!(v, Value::Bool(_) | Value::Error(_)) {
HAlign::Center
} else {
HAlign::Left
}
}
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
};
let h_align = text_align(st.align.h, &v);
let rot = st.align.rotation;
let rotated = rot != 0 && rot != 255;
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
@@ -170,32 +202,7 @@ pub(super) fn draw_text(
let (mut clip_l, mut clip_r) = (x, x + w + across);
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
let need = tw + 2.0 * pad + indent - w;
let grow = |dir: i64, mut need: f32| -> f32 {
let mut ext = 0.0;
let mut col = c.col as i64 + dir;
while need > 0.0 && col >= 0 && col < MAX_COLS as i64 {
let nc = CellRef::new(c.row, col as u32);
if !empty_for_overflow(sh, nc, merges) {
break;
}
let cw = sh.col_width(nc.col) * b.k;
ext += cw;
need -= cw;
col += dir;
if ext > 20_000.0 {
break;
}
}
ext
};
match h_align {
HAlign::Right => clip_l -= grow(-1, need),
HAlign::Center => {
clip_l -= grow(-1, need / 2.0);
clip_r += grow(1, need / 2.0);
}
_ => clip_r += grow(1, need),
}
(clip_l, clip_r) = extend_text_bounds(sh, c, merges, b.k, need, h_align, (clip_l, clip_r));
}
let clip_l = clip_l.max(limits.0);
let clip_r = clip_r.min(limits.1);
@@ -215,7 +222,7 @@ pub(super) fn draw_text(
} else {
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
};
pg.text_rotated(ox, oy, size, font, color, t, angle);
pg.text_rotated(Point::new(ox, oy), size, font, color, t, angle);
}
pg.restore();
return;
+1 -1
View File
@@ -5,7 +5,7 @@ use gridcraft_core::CellRef;
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param, target_ranges, target_sheet};
pub fn specs() -> Vec<CommandSpec> {
vec![
+12 -10
View File
@@ -232,7 +232,9 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
}
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
cx.edits.push((target.to_string(), e.clone()));
let wb = &mut cx.wb;
super::edit::rewrite_all_formulas(wb, target, e);
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -296,7 +298,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
@@ -325,7 +327,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
// New lines take the format of the line before (Excel's "Format Same As Above").
inherit_line_format(sh, axis, at, count);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
rewrite(cx, &name, &Edit::Insert { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})?;
@@ -390,7 +392,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
shift_sheet_features(sh, axis, at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
rewrite(cx, &name, &Edit::Delete { axis, at, count });
}
cx.structural = true;
Ok(Json::Null)
@@ -442,7 +444,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
sh.cells.shift_cols(before, count as i64);
shift_sheet_features(sh, Axis::Cols, before, count, true);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
let mut touched = Vec::new();
{
@@ -480,14 +482,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
for cell in touched {
cx.touch(sheet, cell);
}
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(delete_at + count, -(count as i64));
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
*cx.sel = crate::Selection {
active: CellRef::new(active_row, final_start),
anchor: CellRef::new(active_row, final_start),
@@ -519,7 +521,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
};
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
let right = shift == "right";
@@ -532,7 +534,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
shift_cell_features(sh, &e);
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
cx.structural = true;
Ok(Json::Null)
})?;
@@ -567,7 +569,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
shift_cell_features(sh, &e);
}
// References into the deleted cells become #REF!; the rest move.
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
cx.structural = true;
Ok(Json::Null)
})?;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, CellRef, Ctx, EngineError, Json, Result, Session, Sheet, Visibility, bad, bool_param, edit, json, ok, str_param, target_sheet};
pub(super) fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
+4 -2
View File
@@ -6,10 +6,12 @@
//! `table.insertSlicer`) selects everything, i.e. applies no filter.
use gridcraft_core::CellRef;
use gridcraft_model::*;
use gridcraft_model::{Anchor, AnchorMode, AutoFilter, FilterCriterion, Slicer, Workbook};
use serde_json::{Value as Json, json};
use super::*;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
+12 -3
View File
@@ -97,9 +97,17 @@ struct Solve {
evo_end: Option<EvoEnd>,
}
/// Variable bounds and integrality flags.
#[derive(Clone, Copy)]
struct Bounds<'a> {
lo: &'a [f64],
hi: &'a [f64],
integer: &'a [bool],
}
/// Runs the model's method for `goal` within `lo..hi` from `x0`.
#[allow(clippy::too_many_arguments)]
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], gap: f64) -> Solve {
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bounds, gap: f64) -> Solve {
let Bounds { lo, hi, integer } = bounds;
let any_int = integer.iter().any(|b| *b);
let mut not_linear = false;
let mut evo_end = None;
@@ -203,7 +211,8 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
.into(),
));
}
let Solve { outcome, not_linear, evo_end } = optimise(&mut ev, &model, model.goal, &x0, &prob.lo, &prob.hi, &prob.integer, gap);
let Solve { outcome, not_linear, evo_end } =
optimise(&mut ev, &model, model.goal, &x0, Bounds { lo: &prob.lo, hi: &prob.hi, integer: &prob.integer }, gap);
let (status, message, solution) = match outcome {
_ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None),
Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end {
+1 -1
View File
@@ -2,7 +2,7 @@ use super::lp::{Outcome, Row, Sense};
use super::model::{EvoOptions, Goal};
use super::nlp::{project, shape, violation};
use super::problem::Evaluator;
use super::*;
use super::{MAX_POPULATION, MIN_POPULATION, PRECISION};
// ---------------------------------------------------------------- evolutionary
+199 -155
View File
@@ -1,8 +1,13 @@
use super::problem::Evaluator;
use super::*;
use super::{CellRef, EPS};
// ---------------------------------------------------------------- evaluation
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
/// Objective (gradient, constant) and per-row (gradient, constant).
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Sense {
Le,
@@ -29,6 +34,188 @@ pub(super) enum Outcome {
Limit,
}
struct Map {
var: usize,
sign: f64,
}
struct VariableMap {
columns: Vec<Map>,
offset: Vec<f64>,
bound_rows: Vec<BoundRow>,
}
struct Tableau {
rows: Vec<Vec<f64>>,
basis: Vec<usize>,
width: usize,
decision_cols: usize,
slack_cols: usize,
artificial_cols: usize,
}
fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
let mut columns = Vec::new();
let mut offset = vec![0.0; n];
let mut bound_rows = Vec::new();
for j in 0..n {
let lower = lo.get(j).copied().unwrap_or(f64::NEG_INFINITY);
let upper = hi.get(j).copied().unwrap_or(f64::INFINITY);
if lower.is_finite() {
if let Some(value) = offset.get_mut(j) {
*value = lower;
}
columns.push(Map { var: j, sign: 1.0 });
if upper.is_finite() {
bound_rows.push((vec![(columns.len() - 1, 1.0)], Sense::Le, upper - lower));
}
} else if upper.is_finite() {
if let Some(value) = offset.get_mut(j) {
*value = upper;
}
columns.push(Map { var: j, sign: -1.0 });
} else {
columns.push(Map { var: j, sign: 1.0 });
columns.push(Map { var: j, sign: -1.0 });
}
}
VariableMap { columns, offset, bound_rows }
}
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
let width = columns.len();
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
for (coefficients, sense, rhs) in rows {
let mut row = vec![0.0; width];
for (index, mapping) in columns.iter().enumerate() {
if let Some(value) = row.get_mut(index) {
*value = coefficients.get(mapping.var).copied().unwrap_or(0.0) * mapping.sign;
}
}
let shift: f64 = coefficients.iter().zip(offset).map(|(coefficient, base)| coefficient * base).sum();
transformed.push((row, *sense, rhs - shift));
}
for (entries, sense, rhs) in bound_rows {
let mut row = vec![0.0; width];
for (index, value) in entries {
if let Some(slot) = row.get_mut(index) {
*slot = value;
}
}
transformed.push((row, sense, rhs));
}
// Rows with a negative right side are negated so every right side is >= 0.
transformed
.into_iter()
.map(|(row, sense, rhs)| {
if rhs < 0.0 {
let sense = match sense {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(row.iter().map(|v| -v).collect(), sense, -rhs)
} else {
(row, sense, rhs)
}
})
.collect()
}
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
let width = decision_cols + slack_cols + artificial_cols;
let mut rows = Vec::with_capacity(normalized.len());
let mut basis = vec![0usize; normalized.len()];
let (mut slack, mut artificial) = (decision_cols, decision_cols + slack_cols);
for (i, (coefficients, sense, rhs)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (value, coefficient) in row.iter_mut().zip(coefficients) {
*value = *coefficient;
}
let basic = match sense {
Sense::Le => {
if let Some(value) = row.get_mut(slack) {
*value = 1.0;
}
let basic = slack;
slack += 1;
basic
}
Sense::Ge => {
if let Some(value) = row.get_mut(slack) {
*value = -1.0;
}
slack += 1;
if let Some(value) = row.get_mut(artificial) {
*value = 1.0;
}
let basic = artificial;
artificial += 1;
basic
}
Sense::Eq => {
if let Some(value) = row.get_mut(artificial) {
*value = 1.0;
}
let basic = artificial;
artificial += 1;
basic
}
};
if let Some(value) = row.get_mut(width) {
*value = *rhs;
}
if let Some(slot) = basis.get_mut(i) {
*slot = basic;
}
rows.push(row);
}
Tableau { rows, basis, width, decision_cols, slack_cols, artificial_cols }
}
fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budget: &mut u64) -> Result<(), Outcome> {
if tableau.artificial_cols == 0 {
return Ok(());
}
let mut objective = vec![0.0; tableau.width + 1];
for column in tableau.decision_cols + tableau.slack_cols..tableau.width {
if let Some(value) = objective.get_mut(column) {
*value = 1.0;
}
}
for (i, row) in tableau.rows.iter().enumerate() {
if tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
for (value, coefficient) in objective.iter_mut().zip(row) {
*value -= coefficient;
}
}
}
let all = vec![true; tableau.width];
if matches!(simplex(&mut tableau.rows, &mut tableau.basis, &mut objective, &all, budget), Step::Limit) {
return Err(Outcome::Limit);
}
let scale = normalized.iter().map(|row| row.2.abs()).fold(1.0f64, f64::max);
if -objective.get(tableau.width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Err(Outcome::Infeasible);
}
for i in 0..tableau.rows.len() {
if tableau.basis.get(i).is_none_or(|basic| *basic < tableau.decision_cols + tableau.slack_cols) {
continue;
}
let pivot_col = tableau
.rows
.get(i)
.and_then(|row| (0..tableau.decision_cols + tableau.slack_cols).find(|&column| row.get(column).is_some_and(|value| value.abs() > EPS)));
if let Some(column) = pivot_col {
pivot(&mut tableau.rows, &mut objective, i, column);
if let Some(basic) = tableau.basis.get_mut(i) {
*basic = column;
}
}
}
Ok(())
}
/// Depth-first branch and bound over a relaxation solver; returns the best integer point.
pub(super) fn branch_and_bound(
lo: &[f64],
@@ -201,165 +388,23 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
let n = c.len();
if lo.iter().zip(hi).any(|(l, h)| l > h) {
return Outcome::Infeasible;
}
// x_j = offset + Σ sign·y_k with y ≥ 0.
struct Map {
var: usize,
sign: f64,
}
let mut cols: Vec<Map> = Vec::new();
let mut offset = vec![0.0; n];
let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new();
for j in 0..n {
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
if l.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = l;
}
cols.push(Map { var: j, sign: 1.0 });
if h.is_finite() {
extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l));
}
} else if h.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = h;
}
cols.push(Map { var: j, sign: -1.0 });
} else {
cols.push(Map { var: j, sign: 1.0 });
cols.push(Map { var: j, sign: -1.0 });
}
}
let mapping = map_variables(c.len(), lo, hi);
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
let ny = cols.len();
// Rows over y: (coefficients, sense, rhs).
let mut yrows: Vec<(Vec<f64>, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len());
for (a, sense, b) in rows {
let mut coef = vec![0.0; ny];
for (k, m) in cols.iter().enumerate() {
if let Some(v) = coef.get_mut(k) {
*v = a.get(m.var).copied().unwrap_or(0.0) * m.sign;
}
}
let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum();
yrows.push((coef, *sense, b - shift));
}
for (entries, sense, b) in extra {
let mut coef = vec![0.0; ny];
for (k, v) in entries {
if let Some(e) = coef.get_mut(k) {
*e = v;
}
}
yrows.push((coef, sense, b));
}
let m = yrows.len();
let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count();
// Rows with b < 0 are negated so every right side is ≥ 0.
let normalized: Vec<(Vec<f64>, Sense, f64)> = yrows
.into_iter()
.map(|(a, s, b)| {
if b < 0.0 {
let s = match s {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(a.iter().map(|v| -v).collect(), s, -b)
} else {
(a, s, b)
}
})
.collect();
let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count();
let width = ny + n_slack + n_art;
let mut t: Vec<Vec<f64>> = Vec::with_capacity(m);
let mut basis = vec![0usize; m];
let (mut si, mut ai) = (ny, ny + n_slack);
for (i, (a, s, b)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (v, x) in row.iter_mut().zip(a) {
*v = *x;
}
match s {
Sense::Le => {
if let Some(v) = row.get_mut(si) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = si;
}
si += 1;
}
Sense::Ge => {
if let Some(v) = row.get_mut(si) {
*v = -1.0;
}
si += 1;
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
Sense::Eq => {
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
}
if let Some(v) = row.get_mut(width) {
*v = *b;
}
t.push(row);
}
let is_art = |j: usize| j >= ny + n_slack && j < width;
// Phase 1: minimise the sum of the artificials.
let mut obj = vec![0.0; width + 1];
for j in (ny + n_slack)..width {
if let Some(v) = obj.get_mut(j) {
*v = 1.0;
}
}
for (i, row) in t.iter().enumerate() {
if basis.get(i).is_some_and(|b| is_art(*b)) {
for (v, x) in obj.iter_mut().zip(row) {
*v -= x;
}
}
}
let all: Vec<bool> = vec![true; width];
if n_art > 0 {
match simplex(&mut t, &mut basis, &mut obj, &all, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded | Step::Optimal => {}
}
let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max);
if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Outcome::Infeasible;
}
// Drive zero-valued artificials out of the basis where possible.
for i in 0..m {
if !basis.get(i).is_some_and(|b| is_art(*b)) {
continue;
}
let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS)));
if let Some(j) = j {
pivot(&mut t, &mut obj, i, j);
if let Some(b) = basis.get_mut(i) {
*b = j;
}
}
}
let normalized = normalize_rows(rows, extra, &cols, &offset);
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
return outcome;
}
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
let is_art = |column: usize| column >= ny + n_slack && column < width;
// Phase 2: the real costs; artificials may not re-enter.
let mut cost = vec![0.0; width + 1];
for (k, mp) in cols.iter().enumerate() {
@@ -402,8 +447,7 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`.
#[allow(clippy::type_complexity)]
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec<f64>, f64), Vec<(Vec<f64>, f64)>), &'static str> {
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<Linearized, &'static str> {
let n = x0.len();
let p0 = ev.eval(x0).ok_or("budget")?;
if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) {

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