Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 6f2b7aa2f6 fix(flatpak): allow reading launcher version pins 2026-10-30 18:42:21 -04:00
Dylan M. Taylor dc8078e168 fix(flatpak): allow reading launcher version pins 2026-10-30 18:41:21 -04:00
Brandon Thomas 75f7056e1b Review fixes: keep Plutonium-fream's contributor entry, order ui-parity changelog 2026-10-30 15:42:48 -04:00
Brandon Thomas d0cc17852e Merge PR #2919
# Conflicts:
#	docs/ui-parity.md
2026-10-30 15:42:31 -04:00
Brandon Thomas d43baa2ceb Merge PR #2896
# Conflicts:
#	contributors/people.toml
2026-10-30 15:42:31 -04:00
Brandon Thomas 15ea238fb7 Merge PR #2883
# Conflicts:
#	contributors/people.toml
#	docs/ui-parity.md
2026-10-30 15:42:31 -04:00
Brandon Thomas b2f984a884 Merge PR #2627 2026-10-30 15:42:31 -04:00
Brandon Thomas 4afcaacd44 Merge PR #2620 2026-10-30 15:42:31 -04:00
Brandon Thomas 885a5dd6bc Merge PR #2970 2026-10-30 15:42:31 -04:00
Brandon Thomas 39020af9e9 Merge PR #2955 2026-10-30 15:42:31 -04:00
Brandon Thomas 7abaeba33c Merge PR #2888 2026-10-30 15:42:31 -04:00
Brandon Thomas ecd72eccc4 Merge PR #2851 2026-10-30 15:42:31 -04:00
Brandon Thomas ea0da03e12 Merge PR #2817 2026-10-30 15:42:31 -04:00
Brandon Thomas 569fb8b4c6 Merge PR #2795 2026-10-30 15:42:31 -04:00
Brandon Thomas dec916081a Merge PR #2794
# Conflicts:
#	docs/function-parity.md
2026-10-30 15:42:30 -04:00
Brandon Thomas da3e38750a Merge PR #2769 2026-10-30 15:42:10 -04:00
Brandon Thomas 3e4a2ccc9a Merge PR #2726 2026-10-30 15:42:10 -04:00
Brandon Thomas 27d5930d6b Merge PR #2708 2026-10-30 15:42:10 -04:00
Brandon Thomas 412571bc28 Merge PR #2670 2026-10-30 15:42:10 -04:00
Brandon Thomas 6f77f68fbc Merge PR #2666 2026-10-30 15:42:10 -04:00
Brandon Thomas c305315610 Merge PR #2719 2026-10-30 15:42:10 -04:00
Brandon Thomas e4d8aae522 Merge PR #2870 2026-10-30 15:42:10 -04:00
Brandon Thomas 92256075f2 Merge PR #2722 2026-10-30 15:42:10 -04:00
Brandon Thomas 9e2d5ef17f Merge PR #2718 2026-10-30 15:42:10 -04:00
Brandon Thomas 559a0369c3 Merge PR #2713 2026-10-30 15:42:10 -04:00
Aimar Mendez 1d38a25c8d macOS menu test: use the whole native menu, so clippy passes on a Mac
e6df6f42 removed the #[allow(unused_imports, dead_code)] on the
native_menu module that tests/mac_menu_appkit.rs builds in. That test
only compiles on macOS, so `cargo xtask ci` has failed at clippy on
every Mac since, while the Linux gate stayed green:

- the test called only `install`, leaving `poll`, `sync_windows`,
  `select_theme`, `WINDOW_ITEM` and most `NativeMenu` fields unused;
- the harness = false build has cfg(test) but drops #[test] functions,
  so the unit tests' `use super::*` was an unused import.

The test now also polls the menu with a one-workbook session and checks
that the Window menu lists that workbook (#716), which uses the rest of
the module. The unit tests name `super::` items instead of a glob import.

Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with
CRAFT_FONTS_DIR at the gate's craft-fonts commit), and the new check
fails when the poll is removed (Window menu's last item "" vs "Book1").
2026-10-30 18:19:50 +00:00
MarekSpodBiedry 6f067cce8a Refusal to shift cells off the worksheet names GridCraft, not Excel (#2941) 2026-10-30 14:59:45 +01:00
Dylan M. Taylor 68a4b1a1c3 UPD.1: honor app pretend versions in update checks (#2934) 2026-10-30 02:29:28 -04:00
Dylan M. Taylor f6bcd77b01 Update prompt shows the app icon and accent color instead of a letter (#2928)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1f9d55, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:55 -04:00
Dylan M. Taylor 400e5acd6c CI: workflows only call craft-common-actions at @main (#2921)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:10:15 -04:00
KameronandClaude Fable 5.1 7d38697f71 Test: Command released in the same frame as the arrow press still jumps
Covers the case the fix exists for: an automation keystroke presses and
releases within one frame, so the frame-wide modifier state is already
empty and only the press event carries Command. With the modifiers read
from `i.modifiers` this assertion fails (B3 instead of B1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 15:52:37 -07:00
Dylan M. Taylor 39a10b1342 packaging: Flatpak finds and prints to CUPS printers (#2913)
* packaging: Flatpak finds and prints to CUPS printers

The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.

* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)

Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.

* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path

Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.

* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted

Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.

* packaging: never redirect a CUPS_SERVER the sandbox was given

Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.

* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail

Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
2026-10-29 18:48:40 -04:00
KameronandClaude Fable 5.1 57e40d2d14 Formula pointing: Ctrl+arrow and Ctrl+Shift+arrow jump to the edge of the data
While a formula reference is being pointed from the keyboard, Ctrl+arrow
(⌘arrow on a Mac) moved the pointed cell by one, and Ctrl+Shift+arrow
extended the pointed range by one cell, so `=COUNTIF(`, a click on the top
of a column and ⌘⇧↓ pointed at two cells instead of the column of data.
Both now go to the edge of the data region, as the same keys do on the
selection outside an edit: `point_move` takes `jump` and uses the engine's
`selection::jump`, which Ctrl+arrow on the grid already uses, so the two
agree (last filled cell of a run, else the next filled cell, else the sheet
edge). A pointed range that reaches the sheet edge is written as whole
columns or rows (`A:B`), as Excel writes it.

The editor used to read Shift from `i.modifiers`, the modifier state when
the frame's input was collected; an arrow pressed and released within one
frame (a control client's `ui.key`, an automation tool's keystroke) had
already lost it. It now reads the modifiers carried by the arrow's own key
event, as the grid's keyboard handler does.

Test: ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data (down to the
last filled cell, right across the data, down to the sheet edge, Ctrl+Up
without Shift to the next filled cell, then a plain arrow).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 15:11:55 -07:00
skylar taylor-barrick 6ba1e19857 Merge pull request #1422 from switch87/fix/1419-clear-skips-filtered-rows
Clear: Delete and the Clear commands skip the rows a filter hides (#1419)
2026-10-29 14:18:46 -06:00
fream 9c6de8abde fix(ui): keep chart source outlines below objects (#2861) 2026-10-29 16:49:42 +01:00
Roman Lisunov 0688e83752 Merge upstream main and retain numpad insertion documentation 2026-10-29 14:45:20 +02:00
NetterSilverandClaude Haiku 5.5 b004335538 Keep Fill Series value equal to stop despite float drift
Found while testing crates/engine/src/fill.rs:595-599

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:33:30 +00:00
fream 45cc92ae3c fix(ui): prefill existing hyperlinks in link editor (#2876) 2026-10-29 11:49:33 +01:00
truongitandClaude Opus 5.5 94433a3c4f Document the Agile decryption steps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:51:55 +07:00
Dylan M. Taylor 2ff751791b Merge pull request #478 from prxnvt/pr/macros-ui
Macro interface: Automate › Macros, Macros and Record Macro dialogs, step editor with a debugger
2026-10-29 05:10:50 -04:00
truongitandClaude Opus 5.5 9e0f14cab9 Open Excel's password-protected workbooks again: accept a hash-size integrity key (#2712)
Since #2425 checks Agile encryption's dataIntegrity, every workbook Excel
encrypts fails with "the decrypted data failed its integrity check". The
reader keyed the HMAC with the decrypted encryptedHmacKey cut to the salt
size (16 bytes), as [MS-OFFCRYPTO] 2.3.4.14 describes it, but Excel's key
is as long as the hash: 64 bytes for SHA-512. The check now accepts a key
of either length, so spec-literal writers still open and a modified
package still fails.

The test encryptor writes the integrity key as Excel does, so every
encrypted fixture now has Excel's layout; a new test covers both key
lengths, a key of neither length, and tampering with each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:37 +07:00
Dylan M. Taylor 355840a2a2 Merge branch 'prxnvt/pr/macros-ui' into stack/record
Brings in the macro interface: the Macros dialog and editor, the
Automate ribbon section, drag-and-drop of macro steps, and the
macro overlay on the grid. Ported into the split module layout on main.

Clippy gate: moved wildcard imports to explicit lists, named the
test edit tables, boxed the overlay layer tuple, switched the editor
to craft_ui::ComboBox per clippy.toml, and simplified an is_some_and
in the LP solver.
2026-10-28 23:07:55 -04:00
AnhNgoo bc1e19f1f6 docs(grid): document cell painting and clipping behavior 2026-10-29 10:02:14 +07:00
AnhNgoo d1f063636a test(grid): preserve clipping for fully fitting wrapped text 2026-10-29 09:51:22 +07:00
AnhNgoo 6af06e9d30 fix(grid): preserve full-cell clipping for fitting wrapped text 2026-10-29 09:38:42 +07:00
AnhNgoo f6b900dd5a Merge upstream main and preserve wrapped text clipping fix 2026-10-29 09:18:43 +07:00
Dylan M. Taylor 3fc35e5d3b Merge branch 'stack/debug' into stack/record
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 22:15:51 -04:00
Dylan M. Taylor 3923ab3dca Merge branch 'stack/check' into stack/debug
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 22:08:49 -04:00
Dylan M. Taylor 70bcab56e2 Merge branch 'stack/run' into stack/check 2026-10-28 22:04:06 -04:00
Dylan M. Taylor 708481b615 Merge branch 'stack/tokens' into stack/run
# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/insert.rs
2026-10-28 22:03:59 -04:00
Dylan M. Taylor a1a491b10c Merge branch 'stack/engine' into stack/tokens 2026-10-28 21:59:06 -04:00
Dylan M. Taylor 7bf8ed7495 Merge remote-tracking branch 'origin/main' into stack/engine
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 21:59:05 -04:00
AnhNgoo 9e23c98f3a test(grid): address CodeRabbit feedback for wrapped text clipping 2026-10-29 07:53:37 +07:00
Harrison Booth f01c02cc41 fix: place AutoSum caret inside empty formula 2026-10-28 15:16:55 -05:00
Dylan M. Taylor 146161b4b4 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#2847)
* Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds

Cross-built FreeBSD aarch64 archives were not possible: package.sh took the
arch from uname -m (the x86_64 VM host), built for the host, read binaries
from target/release, and ran the aarch64 gridcraft-cli to print its version.

When CARGO_BUILD_TARGET is set, build with --target, read from
target/<triple>/release, name the archive by the target's arch (aarch64 for
aarch64-unknown-freebsd, x86_64 otherwise), and skip running the binary.
Unset behavior is unchanged.

* Reject unsupported CARGO_BUILD_TARGET in FreeBSD packaging

Only x86_64-unknown-freebsd and aarch64-unknown-freebsd are valid; any other
value now fails before the build or packaging.
2026-10-28 15:35:58 -04:00
Dylan M. Taylor 70327cd3d9 Grant network access so update checks can reach GitHub (#2841) 2026-10-28 13:07:26 -04:00
Brandon ThomasandClaude Opus 5.5 43284377d2 Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:04 -04:00
Dylan M. Taylor 55b5a45af7 Merge pull request #2711 from dylanmtaylor/sonar/enforce-clippy-standards
Enforce Rust coding standards via Clippy
2026-10-28 07:11:38 -04:00
Dylan M. Taylor e6df6f423b Refactor the gridcraft apps for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 6e8de6e724 Refactor ui-egui for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 9717ccbac1 Refactor engine for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 287acf7ad6 Refactor pdf for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 84e684e586 Refactor xlsx for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 7fbf3f60c8 Refactor chart for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 5a84d9bed8 Refactor calc for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 95fcdcc2ca Refactor model for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 7898cc8cfd Refactor functions for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor e17f06fb67 Refactor numfmt for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor 68e929b1d9 Refactor formula for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor fb98f5952b Refactor core for strict Clippy checks 2026-10-28 10:52:53 +00:00
Dylan M. Taylor ad272ca071 Enforce workspace Clippy standards and document lint policy 2026-10-28 10:52:53 +00:00
AnhNgoo f9b869b3a4 fix(grid): prevent partial wrapped text lines in fixed-height rows 2026-10-28 17:03:09 +07:00
AnhNgoo c22f46628c fix(desktop): load preferences before creating startup workbook 2026-10-28 10:47:42 +07:00
AnhNgoo a81cb0f21e test(engine): cover default workbook author and preservation 2026-10-28 10:22:31 +07:00
Harrison Booth ff7ed4b57c Move the ECMA.CEILING revision entry into the revision history table 2026-10-27 21:28:25 -05:00
Harrison Booth 3b73ac9b91 Document ECMA.CEILING as not on Microsoft's list 2026-10-27 21:21:14 -05:00
Harrison Booth 87342cb308 HYPERLINK tests: use the Arg helpers 2026-10-27 21:08:35 -05:00
Harrison Booth 0f78a6b126 HYPERLINK without a friendly name returns the link as text
Excel: HYPERLINK(1) is the text "1" (ISTEXT is TRUE); with a friendly
name the result keeps that value's type.
2026-10-27 21:08:23 -05:00
Harrison Booth 28a8e67e7a functions: add ECMA.CEILING (CEILING under a second name) 2026-10-27 20:57:33 -05:00
Dmussi 8b35f2ac12 Format status bar label and translate its tooltip 2026-10-27 19:20:18 -04:00
RomanLis91andClaude Opus 5.5 a8e03ef40f fix(keyboard): Ctrl with the numpad's + inserts rows, columns or cells, as in Excel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJaF5jevKidVoJcnTyBFub
2026-10-27 23:40:35 +02:00
aiongg 11bb67386c Merge pull request #2704 from Jeremy8776/feat/install-scope-policy
Windows installation policy: GridCraft
2026-10-27 17:01:41 -04:00
aiongg 1decf0ef51 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:47 +00:00
Jeremy d33616dc95 fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:12:54 +00:00
aiongg 89ac453efa Merge pull request #2605 from dotCooCoo/feat/format-chart-pane-stacked-types
Format Chart Area: Chart Type offers the stacked and 100% stacked types and marks the current one
2026-10-27 13:46:40 -04:00
aiongg c596bad63b Merge pull request #2749 from Silvertree2010/perf/export-speed
Speed up XLSX save by writing worksheet XML without per-cell allocations
2026-10-27 13:41:32 -04:00
NetterSilverandClaude Opus 5.5 393a524aa3 Speed up XLSX worksheet writing
Saving a large workbook spent about half its time building worksheet XML,
mostly in small per-cell allocations. The writer now appends to the part
directly:

- Cell references, row numbers, style ids, shared string indexes and
  numbers are appended without temporary strings (push_a1, push_u64,
  push_num), and text is escaped in place (push_esc, with a fast path for
  plain ASCII).
- sheetData walks the cell store in its own row-major order instead of
  copying every address into a BTreeSet and a per-row BTreeMap and looking
  each cell up again. Cells outside the store (pictures, spill values,
  table headers) still go through the sorted set.
- Formulas with nothing the file spells differently (no LET/LAMBDA, no
  _xlfn function, no spill, @, trim operator or structured reference) are
  printed from the shared expression instead of being cloned and rebuilt
  twice by the file rewrite.
- The A1 printer in gridcraft-formula appends references to the output
  instead of formatting each anchor into its own string.

The output is byte-for-byte unchanged.

write_perf, 200,000 rows (2.0M cells, 14.2 MB package), release, Ryzen
16 threads / Linux, 36 interleaved runs each:
  before: median 2083.9 ms, min 1615.7 ms
  after:  median 1501.3 ms, min 1192.3 ms  (-28.0 % median, -26.2 % min)

What remains is mostly deflate (zlib-rs, level 6), about 70 % of the save.

Tests pin the exact sheetData of a workbook covering row settings between
cell rows, spill values, every value kind, number spellings, escaping and
formulas with and without a file spelling (generated with the previous
writer), and check each appending helper and the formula fast path against
the code they replace.

The formula fast path is guarded by a test that runs both paths over every
function in the gridcraft-functions registry (a new dev-dependency, allowed
by the layer rules), in 15 surrounding shapes covering every operator and
rewrite, so a function or rewrite added later without updating the trigger
list fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:46:25 +01:00
Jeremy 3b71e37b70 fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:27 +00:00
aiongg 6303865054 fix(ui): dropdown menus grow back after a search with no matches (#2743)
* fix(ui-egui): dropdown menus regrow while open

Use craft_ui's ComboBox and combo::Menu so a menu that shrank (a filter with
no matches) grows back when the filter is cleared (egui#5225). Repin
craft-libs to one revision.

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:26:30 -04:00
NetterSilverandClaude Opus 5.5 4425fed53f Add an XLSX write benchmark example
`cargo run --release -p gridcraft-xlsx --example write_perf -- [rows] [runs] [out.xlsx]`
builds a sales-style workbook (order ids, dates, categories, numbers, two
formula columns with cached values, booleans, sparse notes, a summary sheet)
and times write_xlsx, printing each run and the median / minimum. An optional
output path keeps the last package for byte comparisons between builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:12:20 +01:00
Jeremy a4c50a6dac docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording 2026-10-27 12:25:19 +00:00
Jeremy a614e683ed docs(installer): describe the per-user default install location and scope 2026-10-27 12:09:03 +00:00
Jeremy e7d778046b fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:01:24 +00:00
Jeremy 998396ad9f fix(installer): address scope and ARM64 review feedback 2026-10-27 12:01:24 +00:00
Jeremy df42619b1e feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:01:24 +00:00
AnhNgoo fa6ccc6ae5 fix(engine): set default workbook author (#2448) 2026-10-27 16:10:46 +07:00
Higgs32584andClaude Opus 5.5 a38aed68cb Security: control channel enforces its 10 s token deadline and caps waiting connections (#2717)
authenticate() set the read timeout on the writing half of the socket while it read the
token through a try_clone; on Windows the clone has its own timeout, so a peer that never
sent a line held its thread forever (and a per-read timeout let a byte-at-a-time peer
stretch it elsewhere). The first line now has one total deadline, set on the handle being
read and shortened before each read. At most 16 connections may wait to authenticate (more
are closed on accept, as #2174 suggested), and accept errors back off instead of spinning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:55:44 -04:00
Higgs32584andClaude Opus 5.5 7d828c01f7 Security: cap the work of lifted lookups, needles x lookup vector, with #NUM! (#2716)
A lookup lifted over an array of needles searched its whole lookup vector once per needle
with no total cap, so =ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576))) in a 1.6 KB file
ran for hours and hung file.open. VLOOKUP, HLOOKUP, LOOKUP, MATCH, XLOOKUP and XMATCH now
return #NUM! past 5e8 needle x entry steps, the pattern MMULT uses. Other lifted functions
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:40:18 -04:00
Higgs32584andClaude Opus 5.5 4cc2c44678 Security: web links open with noopener as a window feature, not in the window name (#2715)
window.open(url, "_blank,noopener") named the new window "_blank,noopener" and passed no
features, so a page opened from a workbook link kept window.opener and could navigate the
editor tab. Pass "noopener,noreferrer" as the features argument instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:39:40 -04:00
Higgs32584andClaude Opus 5.5 ea5292c94a fix(engine): gate the temp-name symlink test to unix
`save_fails_upfront_when_the_temp_name_is_taken` calls
`std::os::unix::fs::symlink` but was only gated on non-wasm, so the
engine crate's lib tests did not compile on Windows. Gate it `cfg(unix)`
like the neighbouring permissions test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:09:30 -04:00
aiongg 45f5ab18e4 Merge pull request #2690 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:31:12 -04:00
skylar taylor-barrick 8117b66635 Merge pull request #2680 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:09:34 -06:00
Harrison Booth 0edf2b850a Fix the spill test: clone the workbook out of its Arc (#2053) 2026-10-26 17:34:04 -05:00
Harrison Booth 2b6e088a6d Engine test: copying a sheet that holds a spill matches a full recalc (#2053) 2026-10-26 17:29:12 -05:00
aiongg 5f041665b5 refactor(ui-egui): move SheetApp methods to app/ 2026-10-26 21:16:41 +00:00
aiongg e0211fbb4f refactor(ui-egui): move SheetApp methods and UI types out of lib.rs 2026-10-26 21:16:41 +00:00
aiongg 6b743e4622 refactor(ui-egui): move backstage.rs items to backstage/ 2026-10-26 21:16:41 +00:00
aiongg 41ab30678c refactor(ui-egui): move control.rs items to control/ 2026-10-26 21:16:41 +00:00
aiongg d840d55c4b refactor(ui-egui): move theme.rs items to theme/ 2026-10-26 21:16:41 +00:00
aiongg b36ec74f13 refactor(ui-egui): move ribbon.rs items to ribbon/ 2026-10-26 21:16:41 +00:00
aiongg da0cb382ae refactor(gridcraft-cli): move check.rs items to check/ 2026-10-26 21:16:41 +00:00
aiongg bbb570dc32 refactor(model): move features.rs items to features/ 2026-10-26 20:43:39 +00:00
aiongg 7f1ccf2e81 refactor(xlsx): move sheet_read.rs items to sheet_read/ 2026-10-26 20:43:39 +00:00
aiongg af54396e64 refactor(xlsx): move sheet_write.rs items to sheet_write/ 2026-10-26 20:43:39 +00:00
aiongg d1c8de48e7 refactor(xlsx): move passthrough.rs items to passthrough/ 2026-10-26 20:43:39 +00:00
aiongg e8f3b6f0cf refactor(xlsx): move pivot.rs items to pivot/ 2026-10-26 20:43:39 +00:00
aiongg 1db55e4e5f refactor(functions): move math.rs items to math/ 2026-10-26 20:43:39 +00:00
aiongg be18ee0519 refactor(functions): move engineering.rs items to engineering/ 2026-10-26 20:43:39 +00:00
aiongg 17b29b891d refactor(functions): move financial.rs items to financial/ 2026-10-26 20:43:39 +00:00
aiongg 1ff44e5ba2 refactor(functions): move stat.rs items to stat/ 2026-10-26 20:43:39 +00:00
aiongg 8b3a6e0362 refactor(engine): move pivot.rs items to pivot/ 2026-10-26 20:43:39 +00:00
aiongg 4b3fd23458 refactor(engine): move cmd/whatif.rs items to cmd/whatif/ 2026-10-26 20:43:39 +00:00
aiongg 506bc8a0f2 refactor(engine): move cmd/sheet.rs items to cmd/sheet/ 2026-10-26 20:43:39 +00:00
aiongg 1a1726bb9d refactor(engine): move cmd/format.rs items to cmd/format/ 2026-10-26 20:43:39 +00:00
aiongg bbff147bf1 refactor(engine): move cmd/extra.rs items to cmd/extra/ 2026-10-26 20:43:39 +00:00
aiongg 0e2335531d refactor(engine): move cmd/data.rs items to cmd/data/ 2026-10-26 20:43:39 +00:00
aiongg 99f0be5f29 refactor(engine): move cmd/pivot.rs items to cmd/pivot/ 2026-10-26 20:43:39 +00:00
aiongg 73c52d49b7 refactor(engine): move cmd/print.rs items to cmd/print/ 2026-10-26 20:43:39 +00:00
aiongg 0069d91e90 refactor(engine): move cmd/edit.rs items to cmd/edit/ 2026-10-26 20:43:39 +00:00
aiongg 228cf80593 refactor(ui-egui): move dialogs.rs items to dialogs/ 2026-10-26 20:43:39 +00:00
skylar 9eb2a3d68e ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:04 -06:00
aiongg 6a40610bb4 refactor(ui-egui): move grid.rs items to grid/ 2026-10-26 19:04:52 +00:00
aiongg 3813413b65 refactor(chart): move render.rs items to render/ 2026-10-26 19:04:52 +00:00
aiongg c5098a0765 refactor(calc): move recalc.rs items to recalc/ 2026-10-26 19:04:52 +00:00
aiongg b0818bccc3 refactor(calc): move eval.rs items to eval/ 2026-10-26 19:04:52 +00:00
aiongg 5a7d2f259e refactor(engine): move cmd/insert.rs items to cmd/insert/ 2026-10-26 19:04:52 +00:00
aiongg 58465af95a refactor(engine): move cmd/solver.rs items to cmd/solver/ 2026-10-26 19:04:52 +00:00
aiongg a8db625e6b style: cargo fmt on the moved test files 2026-10-26 18:15:59 +00:00
aiongg cb661c0051 refactor(ui-egui): move theme tests to src/theme/tests.rs 2026-10-26 18:15:46 +00:00
aiongg 46a406c034 refactor(ui-egui): move grid tests to src/grid/tests.rs 2026-10-26 18:15:45 +00:00
aiongg 27a39472ed refactor(ui-egui): move control tests to src/control/tests.rs 2026-10-26 18:15:45 +00:00
aiongg 3ec086c1bd refactor(ui-egui): move code_editor tests to src/code_editor/tests.rs 2026-10-26 18:15:44 +00:00
aiongg acade653ba refactor(functions): move stat tests to src/stat/tests.rs 2026-10-26 18:15:44 +00:00
aiongg b3711f59ce refactor(functions): move math tests to src/math/tests.rs 2026-10-26 18:15:43 +00:00
aiongg 341ae6848e refactor(functions): move financial tests to src/financial/tests.rs 2026-10-26 18:15:43 +00:00
aiongg e8091a1952 refactor(engine): move io tests to src/io/tests.rs 2026-10-26 18:15:42 +00:00
aiongg 8c338b7cbe refactor(engine): move cmd/whatif tests to src/cmd/whatif/tests.rs 2026-10-26 18:15:42 +00:00
aiongg 6fee0e9134 refactor(engine): move cmd/solver tests to src/cmd/solver/tests.rs 2026-10-26 18:15:42 +00:00
aiongg 1f5c4747df refactor(engine): move cmd/print tests to src/cmd/print/tests.rs 2026-10-26 18:15:41 +00:00
aiongg 2396bb4f64 refactor(engine): move cmd/pivot tests to src/cmd/pivot/tests.rs 2026-10-26 18:15:41 +00:00
aiongg 2b808def4f refactor(engine): move cmd/edit tests to src/cmd/edit/tests.rs 2026-10-26 18:15:40 +00:00
aiongg 97c2a984e0 refactor(engine): move cmd/data tests to src/cmd/data/tests.rs 2026-10-26 18:15:39 +00:00
aiongg da40ecba3a refactor(gridcraft-cli): move check tests to src/check/tests.rs 2026-10-26 18:15:39 +00:00
aiongg 58e33dfef6 refactor(gridcraft): move logging tests to logging/tests.rs 2026-10-26 18:15:32 +00:00
aiongg b3e2a6a2be refactor(xlsx): move crates/xlsx/src/tests/mod.rs to crates/xlsx/src/tests.rs 2026-10-26 18:15:26 +00:00
aiongg 24a18620d2 refactor(xlsx): move crates/xlsx/src/xls/mod.rs to crates/xlsx/src/xls.rs 2026-10-26 18:15:26 +00:00
aiongg e95c29b1fe refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs 2026-10-26 18:15:25 +00:00
Harrison Booth acc32227cb IMSQRT: match the zero real part by pattern (clippy redundant_guards) 2026-10-26 13:03:51 -05:00
Harrison Booth 1fadf98a10 IMSQRT: reproduce Excel's stray real part for negative reals
Excel computes IMSQRT through the polar form, so IMSQRT("-4") is
1.22514845490862E-16+2i rather than 2i. Tests assert strings read back
from Excel; the function-parity doc lists the remaining IM* differences.
2026-10-26 12:58:54 -05:00
Harrison Booth f4f48f3b23 CSV export values: choose the date or time shape from the format section that formats the value 2026-10-26 12:51:42 -05:00
Harrison Booth 9595fc8ee7 CSV export values: pick the ISO shape from the format's date and time parts; guard the file test on wasm32 2026-10-26 12:42:23 -05:00
Harrison Booth 9515c29969 CSV export: file.exportCsv values:true writes raw values for automation (#2648)
The default stays Excel's displayed text, byte for byte.
2026-10-26 12:34:18 -05:00
Dylan M. TaylorandCodebuff d8c5f5885f refactor: reduce cognitive complexity in options, python highlighter, i18n scanner, loop search (#2611)
* refactor: split complex functions for Sonar S3776

Extract helpers from backstage options (general, calculation and iteration
sections), code_editor python (string scanning), xtask i18n literals
(escape and string readers) and calc recalc loops_among (SCC search).
No behaviour change.

* Restore the loop count that loops_among's refactor dropped

The S3776 split moved the loop-detection body into loops_among without
the 'let n = circ.len()' it reads, so calc failed to compile and the
Sonar build broke. Restore the binding from main.

* Extract the SCC pop step from components() to reduce cognitive complexity (S3776)

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-26 09:48:03 -04:00
Florent Siegenthaler 8cfe970ff8 Declare the UI languages to AppKit so the open and save panels are translated 2026-10-26 09:24:52 +01:00
prxnvt e40291fc3d Macro interface: follow per-window dialogs and egui 0.37 accessibility
Main now keeps a dialog with its own workbook window, so the step editor is
parked with its workbook rather than drawn over another one, and closing that
workbook drops the editor without drawing it again: forget_closed_editor
clears it each frame. The Macros, Record Macro and editor text boxes are
labelled by their captions, and the tests pick the question's Cancel and the
chart menu's Delete by role, as the formula bar and ribbon share those names.
2026-10-26 00:38:32 -04:00
prxnvt c801e50e40 Merge #2152 (f8e1f8df, with main at f6b0cec6) into #478
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/io.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-26 00:32:55 -04:00
prxnvt f8e1f8dfc0 Merge #2145 (4d26e395, with main at f6b0cec6) into #2152
# Conflicts:
#	crates/engine/src/io.rs
#	crates/engine/src/lib.rs
#	docs/target-app-parity.md
2026-10-26 00:29:13 -04:00
prxnvt 4d26e395fc Merge #2550 (d4096eee, with main at f6b0cec6) into #2145
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-26 00:26:35 -04:00
prxnvt d4096eee5a Merge #2144 (34eee860, with main at f6b0cec6) into #2550 2026-10-26 00:24:57 -04:00
prxnvt 34eee86053 Merge #2151 (6781aa49, with main at f6b0cec6) into #2144
# Conflicts:
#	crates/engine/src/io.rs
#	crates/engine/src/lib.rs
2026-10-26 00:24:11 -04:00
prxnvt 6781aa4927 Merge main (f6b0cec6) into #2151 2026-10-26 00:21:54 -04:00
prxnvt 483276f719 Merge #465 (3772f2c6, with main at 42bbf6ba) into #2151 2026-10-26 00:21:42 -04:00
prxnvt 3772f2c61a Macros: write the macro names into xlsx again after the main merge
The merge with main kept main's xlsx line in save_bytes_as, which dropped
macros::with_names, so an xlsx save carried no macros.
2026-10-26 00:21:00 -04:00
aiongg f6b0cec67e Merge pull request #2619 from aiongg/g4
Window per workbook (4/4): two windows on one workbook without copying it; Arrange All
2026-10-26 00:00:24 -04:00
aiongg 862dfc1920 Merge pull request #2618 from aiongg/g3
Window per workbook (3/4): a native window per workbook window
2026-10-25 23:59:45 -04:00
aiongg 1b63c3f335 Merge pull request #2617 from aiongg/g2
Window per workbook (2/4): windows in the engine; New Window shares the workbook
2026-10-25 23:58:35 -04:00
aiongg ecb0e546d8 Merge pull request #2616 from aiongg/g1
Window per workbook (1/4): a cell draft stays in its workbook; Switch Windows
2026-10-25 23:57:56 -04:00
Pranav 1cd45cc909 Merge branch 'main' into pr/macros-ui 2026-10-25 23:54:09 -04:00
Pranav e85297e26c Merge branch 'main' into pr/macros-engine 2026-10-25 23:49:20 -04:00
Pranav b552b7a929 Merge branch 'main' into pr/macros-record 2026-10-25 23:49:08 -04:00
Pranav 910b50c1dd Merge branch 'main' into pr/macros-run 2026-10-25 23:48:13 -04:00
aiongg 1f2da4048d Merge g3 into g4 2026-10-26 03:36:29 +00:00
aiongg dbec5c5800 Merge g2 into g3 2026-10-26 03:36:18 +00:00
aiongg 57da3d4641 Merge g1 into g2 2026-10-26 03:35:47 +00:00
aiongg 380be36ac3 G1: the W, W key tip opens Switch Windows through its control 2026-10-26 03:33:34 +00:00
aiongg 5c250a7fea Merge g3 into g4 2026-10-26 03:29:08 +00:00
aiongg c7911c836a G3: craft-libs at storytold main b926b5c0 2026-10-26 03:28:46 +00:00
aiongg 0f5fc81a6c Merge g2 into g3 2026-10-26 03:27:27 +00:00
Dmussi 848b0e658a Click a status bar statistic to copy its value 2026-10-25 23:24:26 -04:00
aiongg 0316765204 Merge g1 into g2 2026-10-26 03:16:42 +00:00
aiongg 320bcfcf54 G1: regenerate the key tip tests 2026-10-26 03:16:42 +00:00
aiongg 7fdceaddc6 Merge g3 into g4 2026-10-26 02:57:21 +00:00
aiongg ec56e80f7f Merge g2 into g3 2026-10-26 02:57:21 +00:00
aiongg b29f05a2c7 Merge g1 into g2 2026-10-26 02:57:21 +00:00
aiongg 5900247075 G1: Switch Windows draws its check with the chrome fallback when no face has ✓ 2026-10-26 02:56:59 +00:00
aiongg f102fe29a1 Merge g3 into g4
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/formulas.rs
#	crates/engine/src/cmd/inspect.rs
#	crates/engine/src/cmd/pivot.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/tabs.rs
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-26 02:45:41 +00:00
aiongg 7085aa1c47 G3: build on current main
Screenshots are admitted in logic for the root viewport and issued from its pass, so nothing
reads the focused native window any more; the field goes.
2026-10-26 02:45:24 +00:00
aiongg 94fcbf8b4c G3: craft-libs at e8b4dde5 (craft-libs#340)
craft-update, craft-window, craft-ui and craft-print all come from one craft-libs revision, the
head of storytold/craft-libs#340 on the aiongg fork, until #340 merges.
2026-10-26 02:37:42 +00:00
aiongg 015b9abe5b Merge g2 into g3
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	ROADMAP.md
#	apps/gridcraft/src/main.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-26 02:37:36 +00:00
aiongg 929495e274 Merge g1 into g2
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/extra.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	docs/control-protocol.md
#	docs/ui-parity.md
2026-10-26 02:37:33 +00:00
aiongg 22aec7e667 G1: build on current main
Scripts can't run the View tab's window commands (Switch Windows, New Window, Hide, Unhide):
they change the active workbook, as window.* steps do. The Switch Windows button names its
accessibility role with egui::Role (egui 0.37).
2026-10-26 02:37:27 +00:00
aiongg 903bece86f Merge upstream main into g1
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-26 02:23:11 +00:00
Dylan M. Taylor 42bbf6baa2 refactor: split complex functions for Sonar S3776 (#2612)
Extract group_bracket from outline layout and move_marks from data
outline_settings. No behaviour change.
2026-10-25 21:39:17 -04:00
Dylan M. Taylor 52cf825395 refactor: split complex functions for Sonar S3776 (#2609)
Extract helpers from auto_sum (selection and single-cell guess), update_cursor
(drag, hover, freeze, and header-edge cursors) and read_styles (dxf reader).
No behaviour change.
2026-10-25 21:37:09 -04:00
dotCooCoo 0b6d243946 Format Chart Area: Chart Type offers the stacked and 100% stacked types and marks the current one
The Chart Type section of the Format Chart Area pane listed nine types,
with stacked column as the only stacked one, so a chart could not be
switched there to stacked bar, 100% stacked column or bar, stacked
line, or stacked or 100% stacked area. It now lists them after their
family's entries, and "Stacked" is renamed "Stacked Column".

Each entry is drawn selected when it is the chart's current type, as
the Legend entries are, using the same type and subtype mapping that
chart.set applies.
2026-10-25 16:53:40 -07:00
Dylan M. Taylor 6f0e43c21c Linux: default to XWayland for file drag-and-drop on Wayland desktops (#165)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:08 -04:00
Dylan M. Taylor 0870b1ad1c Merge pull request #2595 from storytold/chore/deps-safe-refresh
chore(deps): safe dependency refresh
2026-10-25 19:00:54 -04:00
skylar 14dd23db6c chore(deps): refresh serde_json, arboard, muda, winresource, sha2 2026-10-25 16:36:12 -06:00
Dylan M. Taylor ad10881cc3 build: pin craft-update to craft-libs main (495e597) (#2591)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:44 -04:00
Dylan M. TaylorandCodebuff ee7cbf32ab build: pin craft-update to craft-libs main (495e597) (#2586)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:15 -04:00
Dylan M. Taylor 2fbbed33bc Merge pull request #783 from vangel76/fix/control-auth-777
Security: the --control server requires an auth token and closes bad connections (#777)
2026-10-25 16:45:46 -04:00
Dylan M. Taylor 1a36d9db07 feat: startup update prompt modal and direct self-updating (#2566)
* feat: startup update prompt modal and direct self-updating

The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status message. An install holds back while the workbook is unsaved.

Ref: storytold/craft-libs#319

* build: refresh the lock

* build: pin craft-update to craft-libs main (db19c37)

* fix(updates): address review feedback

Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.

* fix(updates): add Check for Updates… to the native macOS Help menu
2026-10-25 15:37:52 -04:00
skylar taylor-barrick 41769f1d16 Migrate GridCraft to egui 0.37 (#2543)
* E37: migrate GridCraft to egui 0.37

Pin the reviewed fork and shared dependencies, adapt input and rendering to the
new frame lifecycle, and preserve bounded capture handling and font notices.
Add representative accessibility checks and meaningful control labels.

Validation: full repository CI passed all ten stages, with 2,249 tests passed,
zero failed and one existing timing test ignored. Native workflows and paired
performance qualification remain documented separately.

* egui037: record final published pins and local qualification

* M0.37: fix RTL clipping and preserve screenshot budgets

* test: share migrated GridCraft UI harness setup

* egui037: document migrated UI and regression contracts

* egui-037: align popup test fonts and avoid redundant tooltip probes
2026-10-25 14:55:00 -04:00
3b65b9af58 Sonar maintainability: explicit imports, method paths, smaller functions (#2412)
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix imports

* Fix serde imports

* Fix engine imports

* Fix clippy gate and a type error from the explicit-import change

Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Apply rustfmt to the explicit-import cleanup so the fmt gate passes

* Share the per-directory font path expansion in theme.rs

The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Read the Windows fonts folder in one place in theme.rs

Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Build the light and dark UI palettes from one table in theme.rs

Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Share the Noto CJK font list between the CJK script lists in theme.rs

The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Share Linux Noto directory list across Thai and Hebrew font lookups

Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.

* Reuse in_dirs for Thai and Hebrew Noto font candidates

Removes the remaining duplicated per-directory loops flagged by SonarCloud.

* Share one font-list builder for the Thai and Hebrew faces

The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 12:32:02 -04:00
Brandon ThomasandClaude Opus 5.5 4eb2fb5c8e Review fix for #2473: a repeated sheet name matches in any case
Sheet1!A1:SHEET1!B2 is a range whose second end repeats the sheet, as
Sheet1!A1:Sheet1!B2 is: the check uses same_name, Excel's case-insensitive
name comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:56:18 -04:00
Brandon Thomas 0665476b35 Merge pull request #2493 from Harrison-Booth:feat/options-iteration-fields
# Conflicts:
#	docs/function-parity.md
#	docs/ui-parity.md
2026-10-25 09:36:59 -04:00
Brandon Thomas 189c3cdb0f Merge pull request #2497 from marcoarnulfo:fix/full-column-extend-scroll 2026-10-25 09:36:38 -04:00
Brandon Thomas 674e1c05a3 Merge pull request #2481 from marcoarnulfo:fix/trace-arrows-stay-on-traced-cell 2026-10-25 09:36:37 -04:00
Brandon Thomas 0469b51c93 Merge pull request #2444 from OdooPudakMachinery:web-chrome-flag 2026-10-25 09:36:37 -04:00
Brandon Thomas 4a44dec500 Merge pull request #2515 from Fubeo:fub/edit-note-keeps-thread 2026-10-25 09:36:36 -04:00
Brandon Thomas 0f5227ddde Merge pull request #2513 from Fubeo:fub/keep-sheet-id 2026-10-25 09:36:35 -04:00
Brandon Thomas 97ec7653aa Merge pull request #2512 from Fubeo:fub/open-saved-without-recalc 2026-10-25 09:36:34 -04:00
Brandon Thomas 27e96a9a58 Merge pull request #2561 from arcanii:autosum-across-a-row 2026-10-25 09:36:34 -04:00
Brandon Thomas 2c81adf94f Merge pull request #2557 from Silvertree2010:fix/2517-a-new-font-derived-from-the-them 2026-10-25 09:36:33 -04:00
Brandon Thomas 9413c57e2c Merge pull request #2555 from Silvertree2010:fix/2407-xlsx-only-one-warning-when-sever 2026-10-25 09:36:32 -04:00
Brandon Thomas bd7b867a1f Merge pull request #2552 from Silvertree2010:fix/h2457-cli-eval-cat-print-huge-or-tiny 2026-10-25 09:36:31 -04:00
Brandon Thomas 80ea166d7d Merge pull request #2551 from Silvertree2010:fix/h2446-deleting-a-row-or-column-leaves 2026-10-25 09:36:31 -04:00
Brandon Thomas cb28634140 Merge pull request #2528 from Silvertree2010:fix/h2369-datetime-from-serial-returns-yea 2026-10-25 09:36:30 -04:00
Brandon Thomas 39a56ee902 Merge pull request #2524 from Silvertree2010:fix/2453-row-height-dialog-uses-pixels-sh 2026-10-25 09:36:29 -04:00
Brandon Thomas 0697abbbbc Merge pull request #2473 from Silvertree2010:fix/h2050-formula-with-a-sheet-qualified-r 2026-10-25 09:36:28 -04:00
Brandon Thomas 6208f15d29 Merge pull request #2463 from Silvertree2010:fix/h2062-yearfrac-basis-0-counts-the-last 2026-10-25 09:36:28 -04:00
Brandon Thomas 25d6a3a44f Merge pull request #2457 from Silvertree2010:fix/h1979-imsqrt-of-a-negative-real-return 2026-10-25 09:36:27 -04:00
Brandon Thomas 1871541948 Merge pull request #2443 from Silvertree2010:fix/h2018-isoweeknum-weeknum-21-return-wee 2026-10-25 09:36:26 -04:00
Brandon Thomas 990aa1315a Merge pull request #2428 from Silvertree2010:fix/h1934-minverse-returns-0-for-inverse-e 2026-10-25 09:36:25 -04:00
Brandon Thomas c9b9907c60 Merge pull request #2417 from Silvertree2010:fix/h1936-percentrank-percentrank-inc-perc 2026-10-25 09:36:24 -04:00
Brandon ThomasandClaude Opus 5.5 f86aec12da Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:18 -04:00
Brandon ThomasandClaude Opus 5.5 bca67944b8 macOS signing: keep the temporary keychain until the build has signed with it
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:00 -04:00
Brandon ThomasandClaude Opus 5.5 4995ecc66e Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:04:14 -04:00
Bryan MarkandClaude Opus 5.5 802c782360 AutoSum: a selected row gets its total to the right, as in Excel
AutoSum over any selection of more than one cell wrote a total under each column, so a row
selected (B2:E2) got four one-cell totals in row 3 instead of =SUM(B2:E2) in F2.

As Excel does, totals now go in the selection's empty last row or column, and otherwise past
it:
- a single row: its total in its last cell when that is empty, else just to its right;
- a block whose last column is empty: each row's total there; whose last row is empty: each
  column's; both: both, and the block's total in the corner;
- otherwise totals under each column, as before.

A whole-row selection still stops at the data's last column, and all totals are one undo step.
Tests: autosum_over_a_row_totals_it_to_the_right,
autosum_over_a_whole_row_totals_it_after_the_data and
autosum_fills_a_selections_empty_last_column_and_row; each fails without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:34:30 +08:00
NetterSilverandClaude Opus 5.5 85b6f564f9 Keep <scheme val="minor"/> on every body-font font on save
Fixes #2517

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:20:17 +01:00
NetterSilverandClaude Haiku 5.5 d07639c7c4 Warn for each skipped drawing object and chart on XLSX open
Fixes #2407

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:18 +01:00
prxnvt 8fd6ec124b Engine: a macro shortcut is one letter, alone or after Ctrl+Shift
`parse_shortcut` took the last letter of any spelling, so `"Ctrl+Alt+M"` was saved as
Ctrl+Shift+M. It now takes a bare letter or one after Ctrl+Shift, Cmd+Shift or ⌘⇧, and refuses
other modifiers.

- Test: `shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones`.
2026-10-25 06:48:57 -04:00
prxnvt 299beb5085 Merge #2145 (bf6a8520) into #2152
# Conflicts:
#	crates/engine/src/record.rs
2026-10-25 06:48:57 -04:00
prxnvt ffacf7405f Docs: Record Macro's shortcut example uses a letter a macro may take
The example was K, which GridCraft uses (Group), so a caller who copied it was refused; it is M
now, in the command's description and on the macro page.
2026-10-25 06:48:57 -04:00
prxnvt 59d85ca138 UI: the macro editor refuses a taken name in the person's words
Saving over a macro now needs `replace: true`, and the engine's refusal says so in terms for
programs. A new draft whose name another macro has (or a kept one from a newer version) is
refused by the editor first, with "A macro called “three” already exists. Choose another name."

- Test: `a_new_macro_never_replaces_one_with_its_name`.
2026-10-25 06:48:57 -04:00
prxnvt ea5eb2ae3c UI: Macro Options saves over the macro it changes
Saving a macro over one with the same name now needs `replace: true`, so Macro Options, which
changes the shortcut and description of a macro that exists, failed to save. It passes
`replace: true`; `options_set_a_shortcut_that_runs_the_macro` covers it.
2026-10-25 06:48:57 -04:00
prxnvt c442da9221 Engine: stopping a recording says when it left the chosen shortcut off
Record Macro checks the Ctrl+Shift letter when recording starts, but
another macro can take it before recording stops; the save then leaves
the shortcut off, and nothing said so. The result now names it in
shortcutDropped, the toast after Stop says the macro was saved without
it, and the macro page describes it.
2026-10-25 06:48:57 -04:00
prxnvt c142c70dbe UI: the macro editor keeps an input to a shape a macro may declare
The format now refuses a single-cell input with a header, several column types or more than one
row or column, a least above its most, and more column types than columns; the editor could make
each of these, and the save then failed.

- Switching an input to "single cell" sets one row and one column, takes the header off and keeps
  at most one column type; "Use current selection as example" does the same for one cell.
- Setting "at least" above "at most" moves "at most" up (and the other way round), and lowering
  the columns' "at most" drops the column types past it.
- Test: `an_edited_input_keeps_a_shape_a_macro_may_declare` (each result passes `check_limits`).
2026-10-25 06:48:57 -04:00
prxnvt 1e2492b4d6 Tests: the large-macro editor test reads the UI clock
Clippy refuses std::time::Instant::now, which traps on wasm; the test
that bounds the step list's frame time reads gridcraft_ui_egui::now_ms
instead.
2026-10-25 06:48:57 -04:00
prxnvt 60c20095e6 UI: the Macros dialog closes when another workbook becomes active
The dialog's rows and its Options pane hold the macros of the workbook
it opened on, but Run, Delete and Options act on the active workbook.
When an agent made another workbook active, the dialog stayed open over
it, and OK in Options would write the first macro's shortcut and
description onto the other workbook's macro of the same name. The
dialog now keeps its workbook and closes when another one becomes
active.
2026-10-25 06:48:57 -04:00
prxnvt a414b608d7 Docs: the macro page says where a question's answer runs, and its long lines wrap
The Questions entry says that an answer runs in the workbook that asked
and does nothing while another one is active. The lines the restack
joined in the recording paragraph and the Macros dialog entry are
wrapped again.
2026-10-25 06:48:57 -04:00
prxnvt a0fd599fe6 UI: the macro step editor reads each step in place
step_at copied the whole step list to look up one step, and the step
list calls it for every row on every frame, so a macro at the 10,000
step limit (which a recording can reach, or a file can carry) took about
ten seconds a frame. The lookup now walks the list without copying it:
five frames of that macro take well under a second. A test opens a
macro at the limit with a generous time bound.
2026-10-25 06:48:57 -04:00
prxnvt 0fdb4d03e4 UI: a macro question is answered only in the workbook that asked it
The Macros dialog's Delete question and the questions a macro run asks
(approve it, overwrite or move the data in the way) keep the command
and run it again with the answer. When another workbook became active
before the answer (an agent can switch workbooks under the prompt),
the answer ran there: Delete removed the other workbook's macro of the
same name. A question now keeps its workbook, and an answer given while
another one is active does nothing and says why.
2026-10-25 06:48:57 -04:00
prxnvt baf01c4211 UI: the macro step editor fits a narrow window
The editor's window had a minimum size of 660 by 380 and its toolbar
was one row of about 680 pixels, so in a window 600 pixels wide Run to
Here and Stop were off screen, and at 480 the inspector's tabs were cut
off. The window is now never larger than the app's window, the name row,
the toolbar and the inspector's tabs wrap (the toolbar's buttons are
disabled in place rather than in a scope of their own, which kept them
from wrapping), the inspector keeps room for its tabs, and its content
scrolls sideways instead of widening the window. Tests check the editor
at 600 and 480 pixels, and the Macros and Record Macro dialogs, which
already fit.
2026-10-25 06:48:57 -04:00
prxnvt f73e7b7e19 UI: closing the window asks about the macro step editor first
A window close (the close button, Alt+F4, quitting) let the app go when
every workbook was saved, losing the editor's unsaved steps, and with an
unsaved workbook it made that workbook active under the editor and asked
for a Save Changes question the editor's window would not let open, so
nothing happened. The close now waits for the editor: it asks about its
debug run and unsaved steps as its own close button does, Cancel keeps
the app open, and once the editor has closed the window close goes on
and asks about unsaved workbooks as before.
2026-10-25 06:48:57 -04:00
prxnvt 12072c5529 UI: a dialog asked for over the macro step editor says why it did not open
The step editor keeps its window until it closes itself, so a dialog
asked for while it is open is not shown. That was silent: File > Close
on a changed workbook asked for its Save Changes question and nothing
happened. The request now shows a message that says to close the
editor first.
2026-10-25 06:48:57 -04:00
prxnvt a18ecc81aa UI: the control channel's dialog cancel closes the macro step editor as its own close does
ui.dialog.cancel emptied the dialog slot, so with the step editor open
it went without its cleanup: the draft stayed behind, the Check overlays
stayed on the grid, the selection the reference boxes moved was not put
back and a debug run went on with no controls. The cancel now closes
the editor the way its close button does, and is refused, with the
reason, while the editor holds unsaved steps or a debug run, which only
its window asks about.
2026-10-25 06:48:57 -04:00
prxnvt a206178209 UI: the macro step editor works only on the workbook it opened in
The macro.* commands act on the active workbook, and the editor stays
open while another one becomes active (File > New, Switch Window, an
agent). A new draft saved then went into that other workbook, and Check,
Run and Debug ran there. The editor now keeps the workbook it opened in:
while another one is active it says so, its controls are disabled,
Save, Check, Run and starting Debug refuse with a message that names
the workbook, the grid overlays are hidden and closing does not put the
selection back on the wrong sheet. Closing the workbook closes the
editor.
2026-10-25 06:48:57 -04:00
prxnvt 73b0edd085 UI: the Macros dialog offers Trust Scripts for scripts from a file
The Macros dialog that replaces the scripts-only list keeps its Trust
Scripts row (#2178): scripts that came with an opened file are listed as
disabled until the row's button trusts them for the session, and Run
works after it. A test covers the row, the trust and the run, and the
macro page describes it.
2026-10-25 06:48:57 -04:00
prxnvt aa86b0c327 UI: merge fallout from the UI kit and the recorder heads
Icon buttons no longer carry an accessible name: the tests that came
with main since the branch was written (menu hover, popup dismissal, the
File menu, More Colors) find Copy, Save and Cancel by label, and the
Quick Access Toolbar and formula bar icons gave those labels twice. The
macro tests need the names of large, small and toggle ribbon buttons
only. The chart menu test takes the menu's Delete, which is drawn after
the ribbon's Home > Cells > Delete. The Trust Scripts row ported into
the Macros dialog is formatted.
2026-10-25 06:48:57 -04:00
prxnvt cec19d6af6 UI: a reference box's pick ends when the box leaves the screen
Choosing another step or inspector tab hid the picking box but left the pick in place, so a grid
selection made later for another reason landed in the box when its step was shown again, and the
draft was dirtied with a reference nobody chose. A pick now ends in any frame that does not draw
its box.
2026-10-25 06:48:57 -04:00
prxnvt 3d6a09ec75 UI: a reference box stops picking when a macro action starts, and debugging never fills one
A reference box that had focus kept picking from the grid while Check, Run or a debugger action
ran. The selections those make landed in the box and dirtied the draft, and a later Check could
save that draft. The pick now ends when an action starts, and while a debug run is active a
selection on the grid is never taken as a pick.

Also: the parity assessment said an .xlsm's VBA project is dropped on open; it is kept unchanged
on save and never run.
2026-10-25 06:48:57 -04:00
prxnvt b9a8fa304c UI: Change in the macro step editor picks only the commands its list shows
In Change mode the picker hid the Repeat, If and Stop rows but still indexed the full
list, so Enter with an empty search (index 0, nothing highlighted) picked Repeat and
turned the command step into a Repeat step; a search for "if" or "stop" did the same.
The control rows are now dropped from the list before it is indexed, drawn or chosen.
2026-10-25 06:48:57 -04:00
prxnvt c523a4c48a UI: another dialog never replaces the open macro step editor
The step editor lives in the app's one dialog slot, so Format Cells, the Macros
dialog or New Macro opened while it was up took its place. The draft was lost
(New Macro started a fresh one), and a closed-over editor left its grid overlays and
its debug run behind. While the editor is open, requests for other dialogs are now
ignored; the editor closes itself as before.
2026-10-25 06:48:57 -04:00
prxnvt eb7b2b93f4 UI: the macro editor remembers a rename even when the save after it fails
Save renames the stored macro first and then saves the draft. When the save failed
(a draft too large to keep in the workbook, say), saved_as still held the old name,
so every later Save, Check, Run and debug start tried to rename a macro that no
longer existed. saved_as now takes the new name as soon as the rename succeeds.
2026-10-25 06:48:57 -04:00
prxnvt bca9a10f8a UI: saving a new macro never replaces one with the same name
A new draft's name can be edited to that of a macro already in the workbook. Save, and Check and
Run (which save first), then sent macro.save with its default replace: true and the draft took
the other macro's place. A new draft now saves with replace: false, so the clash is reported and
the existing macro stays as it was.
2026-10-25 06:48:57 -04:00
prxnvt 1443ec417c UI: macro step editor with a debug bar and grid overlays
New Macro (Automate › Macros) and Step Into, Edit and Create in the
Macros dialog open a resizable step editor. It works on a draft of the
macro and saves it with macro.save.

The step list shows each step's number, a breakpoint dot in the gutter
(click to set or clear), a category icon, a summary, an error marker
and an enabled checkbox; Repeat bodies and If branches are indented
under their step, and rows drag to reorder within their list. Add Step
opens a command picker (control steps first; commands a macro may
never run are left out) and fills in the command's required
parameters; Duplicate and Delete act on the selected step.

The inspector's Details tab edits the selected step. Reference
parameters are reference boxes that take a typed reference or a
selection on the grid, with a Rel/Abs switch between a token relative
to the example input and an absolute reference, and token suggestions.
The Inputs tab sets each input's shape, header and column types (Use
current selection as example infers them); the Output tab sets the
anchor and the conflict rule.

The debug bar has Check (a dry run that colours the grid: inputs blue,
writes green, writes over data red), Run, Step, Continue, Run to Here
and Stop, which asks whether to keep the changes or undo them. The
debugger receives the editor's breakpoints with breakpointsOnly, so a
dot cleared while paused takes effect at once; the current step is
selected, its parameters are shown as they will run, and its target is
outlined on the grid. While a macro is debugged, the status bar says so
and Show reopens the editor.

List drag and drop lives in a small dnd module. The debugger icons are
drawn in code, and the breakpoint and sheet overlay colours are theme
tokens covered by the contrast test.
2026-10-25 06:48:57 -04:00
prxnvt 5512c1a269 UI: Automate › Macros group, Record Macro and Macros dialogs, recording status, macro shortcuts
The Automate tab gains a Macros group: Macros opens the Macros dialog,
Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut
letter, description, Use Relative References) and turns into Stop
Recording while recording, Use Relative References sets how the next
recording starts, and Run Macro runs the macro run last; its menu lists
the recent macros, then every macro. While recording, the status bar
shows a red dot, "Recording · N steps" and Stop.

The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the
workbook's macros with their shortcuts, then its Automate scripts, and
says when the file carries a VBA project, which is kept and not run.
Run, Delete (which asks first) and Options (shortcut letter and
description) act on the selected row. The dialog keeps the result of
view.macros, so the scripts and the VBA entry are listed as before.

A macro that needs approval asks with Run, and one whose output would
land on data asks with Overwrite, Shift Down and Shift Right; each
button runs the command again with its answer. Ctrl+Shift+letter
(Cmd+Shift on a Mac) runs the macro with that shortcut; the letters
GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV)
now open the Record Macro and Macros dialogs.

Icons for macros, recording and relative references are drawn in code,
the recording dot is a theme token covered by the contrast test, and
large and small ribbon buttons and icon buttons carry accessible names.
2026-10-25 06:48:57 -04:00
prxnvt 7c66aaa2d3 Engine: Record Macro takes a name, shortcut and description
macro.record checks name, shortcut and description when recording
starts and keeps them for the save, so a name that is taken, too long
or holds line breaks, or a Ctrl+Shift letter that GridCraft or another
macro uses, is refused before anything is recorded. With empty
parameters it records under the next MacroN, as before. A name or
description given when stopping still wins, and a shortcut that another
macro took while recording is dropped.

The shortcut rules (macros::parse_shortcut, shortcut_text,
shortcut_conflict, RESERVED_SHORTCUTS and check_new_name) live in the
engine, so the Record Macro dialog, Macro Options and agents agree.
2026-10-25 06:48:57 -04:00
prxnvt bf6a8520b8 Merge the footprint pull request (65634c2b, with #2144 and #2151) into #2145 2026-10-25 06:48:03 -04:00
prxnvt 65634c2bb7 Merge #2144 (b9aa4a6a) into the footprint pull request 2026-10-25 06:48:03 -04:00
prxnvt 0533480c97 Engine: recorded steps carry a number, so a group's rollback finds its own after others
A group remembered the recording's length when it began, which another workbook's rollback (or a
recording started again inside the group) made point at the wrong steps. Each kept step now has
a number, and each recording one of its own; a group remembers both, and its rollback takes back
the steps of its workbook numbered from its mark, or all of them when the recording started
inside the group.

- Test: `rollbacks_take_back_their_own_recorded_steps_in_any_order` (groups on two workbooks
  rolled back in turn, and a recording started again inside a group).
2026-10-25 06:48:03 -04:00
prxnvt b9aa4a6a9c Merge #2151 (0533480c) into #2144 2026-10-25 06:48:03 -04:00
prxnvt ca2be34e8a Engine: a rolled-back group takes what its script runs ran out of the recording too
Taking back the steps a rolled-back group recorded left what a taken-back script or macro run
ran (`Recorder::ran`) under its old index, where a later run could pick it up, and left it out of
the size count. `cut_back` now drops it with its step, moves a kept run's entry to the step's new
index and counts both again.

- Test: `a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording`.
2026-10-25 06:29:23 -04:00
prxnvt 9a22c4b2f2 Merge the footprint pull request (24f0c3df, with #2144 and #2151) into #2145 2026-10-25 06:28:06 -04:00
prxnvt 24f0c3df92 Merge #2144 (1a00ce19) into the footprint pull request 2026-10-25 06:28:06 -04:00
prxnvt 1a00ce19fb Merge #2151 (0a929eaf) into #2144 2026-10-25 06:28:06 -04:00
prxnvt 0a9305f784 Merge #2145 (9a22c4b2) into #2152 2026-10-25 06:28:06 -04:00
prxnvt 0a929eaf68 Engine: a rolled-back group takes back only the steps recorded in its workbook
A rollback took back every step recorded since its group began, so an edit recorded in another
workbook while the group was open was lost from the recording although the edit stayed. It now
takes back only the steps recorded in the group's own workbook.

- Test: the other workbook's edit in `stop_action_runs_only_on_its_own_and_is_not_recorded`.
2026-10-25 06:27:53 -04:00
prxnvt 2e8284c191 Engine: a panic during a dry run puts the journal pause and the touch log back at any depth
The pause of a dry run was cleared only by a panic at the top level. A command that started a
dry run, nested in another, and panicked left journaling paused for the rest of the session (and
the touch log open) once the outer command finished. Each command now keeps the pause count it
started with, and its panic guard puts it back, closing the touch logs, whatever its depth.

- Test: `a_dry_run_cut_short_by_a_panic_leaves_journaling_on`.
2026-10-25 06:26:51 -04:00
prxnvt f0b667aa02 Merge the footprint pull request (361fff98, with #2144 and #2151) into #2145 2026-10-25 06:02:18 -04:00
prxnvt ec58ec5f4b Merge #2145 (f0b667aa) into #2152 2026-10-25 06:02:18 -04:00
prxnvt 361fff982a Engine: a dry run journals nothing, so the journal and Record Actions' start stay as they were
Every nested command a dry run executed was journaled. Past the journal's limits that evicted
the person's own oldest entries, and the restore could only cut back what was left, so one
`macro.check` of a long macro could empty the journal and move a Record Actions recording's
start. While `Saved` holds the session's state, commands are not journaled
(`Session::journal_paused`), and the restore puts the journal and the recording start back
exactly; the count of dropped entries it relied on is gone. The fallible calls of a dry run come
before the pause, and a panic at the top level turns journaling back on.

- Test: `a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were` (a full journal, a
  Record Actions start, and a check of 12,000 nested steps; replaces the test that accepted the
  loss).
2026-10-25 06:02:18 -04:00
prxnvt fe14c0d86e Merge #2151 (0aae1ffe) into #2144 2026-10-25 06:00:19 -04:00
prxnvt 891986bf66 Merge #2144 (fe14c0d8) into the footprint pull request 2026-10-25 06:00:19 -04:00
prxnvt 0aae1ffeea Engine: an xlsx save refuses a macro that skipped the check, as a JSON save does
`with_names` checked only the size of each item, so a macro built in code past the model's
limits (a name with a line break) was written to the file and dropped the next time it opened.
The xlsx save now runs `check` on every macro first.

- Test: the macro past the limits in `a_macro_built_in_code_goes_through_the_size_check_too`.
2026-10-25 06:00:13 -04:00
prxnvt bfcf50aad3 Engine: rolling back an undo group takes back the steps it recorded
Stop Action (or any rollback) put a group's edits back but left the steps recorded while the
group was open in the recording, so playing the recording back made edits the workbook no longer
had. A group remembers where the recording was when it began, and its rollback cuts the
recording back to there.

- Test: `stop_action_runs_only_on_its_own_and_is_not_recorded`, which kept the rolled-back edit
  before.
2026-10-25 06:00:13 -04:00
NetterSilverandClaude Haiku 5.5 64a78a7210 Print huge and tiny CLI numbers in exponent form
Found while testing apps/gridcraft-cli/src/main.rs:196-199 (number_text, used by value_text and check.rs:627)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:44:39 +00:00
NetterSilverandClaude Haiku 5.5 80e2041159 Remove sparklines when their row or column is deleted
Found while testing crates/engine/src/cmd/sheet.rs:~199 (shift_sheet_features, sparklines loop)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:39:12 +00:00
prxnvt f225cd1026 Engine: Check Macro waits while a macro is being debugged
`macro.check` is a query, so the debugger's lock let it through, but its dry run runs the macro's
steps, which the lock refuses: the check reported the lock as the failure of a step. It now waits
with "Finish or stop the macro first", as other commands that change the workbook do.

- Test: `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
2026-10-25 05:16:37 -04:00
prxnvt 9fe25835d4 Merge #2145 (f225cd10) into #2152 2026-10-25 05:16:37 -04:00
prxnvt f6f4585c93 Engine: a dry run puts the document's revision back
The dry run's rollback counts as a change of the document and moves its revision on, which front
ends key their caches on, so a `macro.check` that changed nothing (and a shift it tried and took
back) looked like a change. `Saved` keeps the revision and puts it back with the rest.

- Test: the revision in `shifting_makes_room_and_is_checked_again`.
2026-10-25 04:14:12 -04:00
prxnvt 91bb5eded8 Merge the footprint pull request (f6f4585c, with #2144) into #2145 2026-10-25 04:14:12 -04:00
prxnvt 5aa10132aa Merge #2145 (91bb5ede, with the footprint pull request and #2144) into #2152 2026-10-25 04:14:12 -04:00
prxnvt 267d5bc503 Merge #2144 (90a46da4) into the footprint pull request
# Conflicts:
#	crates/engine/src/cmd/macros.rs
2026-10-25 04:13:22 -04:00
prxnvt 90a46da496 Engine: macro.run's description lists only what it returns here
`shifted` comes with the footprint in the pull request above; here a shift rule stops the run as
`fail` does, which the description now says.
2026-10-25 04:13:05 -04:00
prxnvt 00b22d5e33 Docs: the macro page keeps what the pull requests below wrote
The merge of #2145 took this branch's macro page whole instead of merging it, which left out the
page's newer text from below: the debugging section as #2145 rewrote it (the lock, Stop Action,
parameters, the time limit for each action), the time limit for each debugger action in the
limits, the check after a shift, and moves that rewrite formula references. The page is now the
merge of both, with this branch's own scope line.
2026-10-25 03:55:52 -04:00
prxnvt fdd26876fb Merge #2145 (227078f5, with the footprint pull request and #2144) into #2152
# Conflicts:
#	docs/macros.md
2026-10-25 03:21:39 -04:00
prxnvt 227078f56a Merge the footprint pull request (dadffdb3, with #2144) into #2145
# Conflicts:
#	docs/macros.md
2026-10-25 03:21:31 -04:00
prxnvt dadffdb33f Engine: macro.check shifts and checks again, as the run does, before it says ok
Under `shiftDown` or `shiftRight` with conflicts, `macro.check` looked only for the refusals made
before a shift, so it said `ok` for a run the second footprint stops (an anchor that moves with
the shifted data). It now shifts the data inside an undo group, takes the footprint again, rolls
both back and puts the session back, and reports what still stands in the way.

- Test: the `Named` case and a shift that makes room in `shifting_makes_room_and_is_checked_again`.
2026-10-25 03:21:15 -04:00
prxnvt e9effe5dbd Merge #2144 (198fe482) into the footprint pull request
# Conflicts:
#	docs/macros.md
2026-10-25 03:20:11 -04:00
prxnvt 198fe482a1 Engine: the run's check of each step finds the inputs on their sheet after a sheet moved
- The check for writes over data compared the inputs' sheet with the sheet's index when the run
  started, while the inputs follow their cells: after a step inserted a sheet in front, a step
  writing into its own input on the moved sheet was stopped as writing over data. It now uses the
  sheet's index now. Test: the input case in
  `inputs_and_the_output_follow_their_cells_when_a_step_moves_them`.
- Docs: the page says `macro.run` checks the macro, its inputs and parameters and the policy
  before it runs, and that a formula whose references a move rewrites counts as changed too.
2026-10-25 03:19:59 -04:00
prxnvt 43134b308e Docs: catalog count 276 / 292 in the parity summary; macro storage names; the macro page's scope
- target-app-parity.md: the summary table and the method say 276 / 292 and 292 unique ids, as
  `cargo xtask parity` measures; Last updated and revision history.
- architecture.md: macros are kept as `_gc_macro_<n>_<k>` (CodeRabbit 4366060044).
- macros.md: the intro says the page covers the engine and its commands, without implying
  later work.
2026-10-25 03:19:35 -04:00
prxnvt 460a72d3bb Engine: a recording keeps what ran and plays back as it was recorded
- A script step that only opened a dialog (a menu-style call that left the workbook unchanged)
  is left out of the script run's steps, by the recorder hook's rule; a script run whose steps
  the recording couldn't keep is left out and listed in `skipped`, not read from the workbook
  as the script stands when recording stops (a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording).
- A script's toggles keep the state they set (`"on": true`), as a top-level Bold does
  (a_script_run_records_the_state_its_toggles_set).
- A recorded macro.run keeps the steps it ran, as a script run does, so saving or deleting the
  macro before the recording stops changes nothing (a_recorded_macro_run_keeps_the_steps_that_ran).
- The example input's shape and column types are inferred when recording starts, not from the
  data the recording changed (the_recorded_input_is_inferred_as_it_was_when_recording_started).
- Recorded text that playback would read as a token stays text: a reference parameter that reads
  as one becomes {"$text": …}, and `{{` becomes `{{{{`
  (recorded_text_that_reads_as_a_token_plays_back_as_text). The resolver (below #2152) gains
  `{"$text": …}`, text passed as it is (text_marked_as_text_is_never_a_token).
- A recording over the macro size limit is cut at the limit and the steps left out are counted
  in `dropped` (a_recording_past_the_macro_size_is_cut_and_counted).
- A macro recorded in a workbook opened from a file is approved for the session, since the
  person recorded it (a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking).
- macro.record refuses parameters it would ignore: name or description when starting, relative
  when stopping, stop: false while recording, others beside cancel: true, and wrong kinds
  (record_refuses_parameters_it_would_ignore).
- Undo while recording keeps the undone step, as in Excel; documented
  (undo_while_recording_keeps_the_undone_step_as_excel_does).
- Docs: macros.md Recording and Tokens; module comments no longer say scripts are read.
2026-10-25 03:19:35 -04:00
prxnvt 8b018d89cd Model: the undo budget counts each macro's whole JSON, parameters included
- Macros::estimated_bytes counted names, descriptions and step counts but not step parameters,
  so each save, rename or delete (which copies the macro list an undo step shares) could keep a
  4 M-character parameter per step unseen by the 128 MB budget. It now counts each macro's JSON
  as it is written, without building it.
- This changes the macro storage layer below #2152, the smallest change that makes the budget
  hold (answers CodeRabbit thread 4366060037).
- Test: re_saving_a_large_macro_keeps_the_undo_history_within_its_budget.
2026-10-25 03:19:03 -04:00
prxnvt bc0d3035fb Engine: kept macros count toward the macro limit and keep their names; saving over a macro needs replace: true; renaming checks the size
- macro.save and a stopped recording count the macros kept in a newer format with the readable
  ones, so a save never holds more than the next open keeps; the message names the kept count
  (kept_macros_count_toward_the_limit_and_keep_their_names, which saves and reopens as xlsx and
  JSON).
- The names of kept macros are taken: macro.save and macro.rename refuse them, unique_name and
  next_name step around them (same test).
- macro.save no longer replaces by default: `replace` defaults to false, the error names the
  existing macro and says to pass `replace: true`, and a `replace` that is not a boolean is
  refused (saving_over_a_macro_needs_replace).
- macro.rename runs the whole macro check, size included, so a longer name can't take a macro
  past what an xlsx file holds (renaming_checks_the_macro_size).
- Docs: macros.md, the management commands and kept macros.
2026-10-25 03:18:26 -04:00
prxnvt f66ebd6fb2 Docs: debugging macros: actions, start options, the lock and the time limit per action
- The start options are those of `macro.run`, refused as it refuses them.
- The time limit applies to each debugger action; the step and parameter limits to the whole run.
- The lock lists what runs between steps, and says the sheet and selection are put back.
- Stop Action ends the run; the debugger is never a step and never recorded.
- The answers' fields, including `changedCells`, `shifted`, `wroteFiles` and `message`.
2026-10-25 03:17:42 -04:00
prxnvt 7dad14e77e Engine: the macro debugger locks its workbook to looking around, ends with Stop Action and checks its parameters
- `macro.debug` refuses to run as a step of a script or macro, and is never recorded (the
  recording buffer and Record Actions' journal both leave it out); a debugger whose undo group
  something else closed locks nothing, and its next action says the run had ended instead of
  reporting a keep or a rollback. Tests: `the_debugger_runs_on_its_own_and_is_never_recorded`,
  `a_debugger_whose_group_was_closed_elsewhere_locks_nothing`.
- Stop Action is no longer refused under the debugger: it rolls the run back, ends the debugger
  and names the macro. Test: `stop_action_stops_a_macro_being_debugged` (was
  `stop_action_waits_while_a_macro_is_being_debugged`).
- Between steps the debugged workbook runs only `macro.debug`, queries and an allow-list of
  commands that look around (selection, sheet changes, Go To, Find, Copy, zoom, Switch Window,
  Close, Stop Action); everything else is refused, saving and view modes included. The run's
  active sheet and selection are put back before its next step. Test:
  `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
- Only the top-level `macro.debug` call rolls the run back after a panic; a panic nested in a step
  is that step's error. Test: `a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on`.
- `keep` and `breakpointsOnly` must be true or false, breakpoints must be step paths of the macro
  (at most 10,000), and the action is checked before breakpoints change. Test:
  `debugger_parameters_are_checked_before_anything_changes`.
- Answers: `executed` counts the run's own steps (not the footprint's), `shifted` and `wroteFiles`
  are reported, a rollback's message names the files that stay, `changed` lists deleted and
  renamed sheets and at most 500 entries with `changedCells`, and a Repeat over whole rows or
  columns shows the part in use. Tests: `the_debuggers_answers_count_its_own_steps_and_say_what_stays`,
  `the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list`,
  `the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them`.
2026-10-25 03:17:42 -04:00
prxnvt d309c85c31 Engine: Stop Action waits while a macro is being debugged
`automate.stopAction` rolls back the undo groups open on the workbook. While a macro was being
debugged it rolled back the debugger's own group and left the debugger set, pointing at a group
that was gone. It is now refused under the debugger's lock with "Finish or stop the macro first",
as edits are; `macro.debug {action: "stop"}` ends the run.

- Test: `stop_action_waits_while_a_macro_is_being_debugged`.
2026-10-25 02:52:43 -04:00
prxnvt 60e0039524 Merge #2145 (ce2ab9a4, with the footprint pull request and #2144) into #2152
# Conflicts:
#	docs/macros.md
2026-10-25 02:50:32 -04:00
prxnvt ce2ab9a473 Merge the footprint pull request (c715c36c, with #2144) into #2145
# Conflicts:
#	docs/macros.md
2026-10-25 02:50:17 -04:00
prxnvt c715c36c07 Merge #2144 (2cf5b808) into the footprint pull request
# Conflicts:
#	docs/macros.md
2026-10-25 02:49:50 -04:00
prxnvt 2cf5b808b3 Docs: the macro page's conflict rules wrap at the page's width 2026-10-25 02:49:42 -04:00
prxnvt 3708c80168 Engine: macro.check says whether the run would go ahead; the question names what the answer covers
- `ok` in `macro.check` meant only that the dry run hit no error. It now says whether
  `macro.run` with the same parameters would go ahead without a question, and `errors` says
  why not: a failing step, a Stop step that rolls back, data written over under `ask` or `fail`,
  a shift that can't make room (the refusals `make_room` makes, now in `shift_refusal`), or a
  run past the step or parameter limit once the dry runs before it count. `macro.check` takes
  `onConflict` and `overwrite` as `macro.run` does, reports the rule it used, and refuses the
  parameters `macro.run` refuses.
- After a partial footprint, the question under `ask` says where the check stopped and that
  overwriting also lets the later steps write over data unchecked.
- The dry runs and the run share the parameter budget as they share the step budget.
- A dry run puts back the Go To list (`edit.goTo` with `record: true`).
- The footprint groups the touch log by sheet once, instead of filtering a million entries once
  per sheet.
- Docs: what `ok` means; RAND's sequence moves on after a check, as after any recalculation.
- Tests: `the_check_says_whether_the_run_would_go_ahead`,
  `the_question_after_a_partial_footprint_says_what_the_answer_covers`,
  `a_dry_run_puts_back_the_go_to_list`, the shared parameter budget, and `ok` under `ask` in
  `conflicts_ask_fail_or_overwrite`.
2026-10-25 02:49:35 -04:00
prxnvt 567ed0abc9 Merge #2144 (65cbcdd6) into the footprint pull request
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/macros/run.rs
#	crates/engine/src/tests/macros.rs
#	docs/macros.md
2026-10-25 02:45:42 -04:00
prxnvt 65cbcdd6bd Engine: a macro run names the files it kept, refuses parameters it would ignore, and bounds its work
- A run that rolls back after writing a file no longer says "Nothing was changed.": the message
  says the workbook was put back and names the step whose file stays (`StepError::wrote_files`,
  also after a Stop with rollback).
- `macro.run` refuses an input the macro doesn't have (a misspelt input bound the selection),
  an input that isn't reference text, and `onConflict`, `overwrite`, `approve` or `timeLimit` of
  the wrong kind, which it used to ignore; `null` still counts as not given.
- A step no longer copies itself, and Repeat and If no longer copy their bodies, on every pass:
  a body is copied once per run. A run passes its commands at most 256 MB of parameters, all
  steps together, which bounds a run's work on the web build, where there is no clock.
- Every step except an approved file step runs inside the scripts' file sandbox, so a command
  that touches a file under a parameter other than `path` can't do so from a macro.
- Docs: moving cells that hold data counts as writing over them.
- Tests: `run_parameters_that_would_be_ignored_are_refused`,
  `a_run_passes_a_bounded_amount_of_parameters`, and the rollback message in
  `each_step_is_checked_for_writes_over_data`.
2026-10-25 02:44:15 -04:00
prxnvt ad79b071ce Engine: a macro's inputs, output and Repeat ranges follow their cells when a step moves them
Inputs, the output and a Repeat's range were bound once, by sheet index and coordinates. A step
that inserted a sheet in front of an input's sheet sent every later token to the new sheet, and
rows inserted above an input left the tokens on the cells that moved into its place; the run
wrote into the wrong cells and reported success.

- Commands record the structural edits they make as they rewrite references (`Ctx::edits`; while
  a macro step runs, `DocState::edits` collects them).
- After each step the machine matches the sheets as the run's guard does and moves the inputs,
  the output and the ranges of the Repeats in progress by those edits, with the formula engine's
  own adjustment.
- A step that deletes an input's or the output's cells, or their sheet, or changes the size of a
  range a Repeat is going over, stops the run, which rolls back.
- Test: `inputs_and_the_output_follow_their_cells_when_a_step_moves_them` (a sheet inserted in
  front, rows inserted above, an input deleted, a Repeat's range grown).
2026-10-25 02:40:21 -04:00
prxnvt d053b1f548 Engine: a run is checked step by step even when its footprint was complete
A footprint is not proof of what the run will do. RAND gives the run other values than the dry
run before it (the recalculation's random sequence goes on), and NOW moves on, so an If can take
another branch in the run and write over data the dry run never reached; under `fail` the run
went ahead and wrote over it.

- Unless the rule is `overwrite`, every run checks each step as it goes, as a run after a
  partial footprint already did; a write the footprint didn't see stops the run, which asks under
  `ask` and fails otherwise, and rolls back.
- Test: `a_run_that_takes_another_path_than_its_check_is_still_checked` (an If on `=RAND()` over
  40 runs under `fail`; before, one of them wrote over B1).
2026-10-25 02:35:02 -04:00
prxnvt 0719458114 Engine: macro.check and the footprint: where a macro writes, worked out before it runs
Split from the pull request below at the review's request; the code is the runner's footprint
as reviewed there, unchanged.

- `macro.run` first runs the macro in dry-run mode on the real workbook (unless the rule is
  `overwrite`) and puts everything back: the workbook, its selection and history through the
  dry run's undo group, and the session state a dry run may touch (`Saved`: the active
  workbook, open documents, the saved mark, selections, unlocked ranges, trust, clipboard,
  preferences, mode, journal and recording, queued requests, format painter, find, ruler,
  hidden windows, drawing tool and the comment panes).
- Cells it would write outside its inputs that hold data, and sheets and objects it would
  delete, are conflicts: `ask` asks before anything runs, `fail` fails, and `shiftDown` and
  `shiftRight` insert cells over the block of every written cell and check again. A shift is
  refused when it would move an input, when the macro deletes sheets or objects, or when the
  touch log is full.
- A dry run stops before a file step and before Solver; the footprint is then partial, and the
  run checks each step as it goes.
- The dry runs and the run share one clock, one time limit and one step budget.
- `macro.check` reports the inputs, the output anchor, approval needed and the footprint.
- Tests: the footprint tests that moved with this code.
2026-10-25 02:33:26 -04:00
prxnvt 5860857937 Engine: a macro run checks each step for writes over data; the check before the run moves to its own pull request
The review asked for the runner to be split. This pull request keeps the policy, the input
checks, the step machine and `macro.run`; working out before a run where a macro writes (the
footprint dry run, shifting data to make room, `macro.check`) moves to the pull request above it.

- Unless the conflict rule is `overwrite`, every run now checks each step as it runs, which the
  run did before only after a partial footprint: a step that writes over cells outside the inputs
  that held data when the run started, or deletes sheets or objects that were there, stops the
  run, which asks (`ask`) or fails, and the run rolls back.
- `shiftDown` and `shiftRight` need the footprint, so here they stop the run as `fail` does.
- Moved: `Footprint`, `Saved`, `footprint`, `diff`, `make_room`, `macro.check`, the dry-run
  branches of the machine, `policy::previewable`, `DocState::touched` and the touch log,
  `Session::journal_dropped` and `truncate_journal`, and the tests of those.
- Test: `each_step_is_checked_for_writes_over_data` (the review's counterexample: a permitted
  file step or Solver, then a write over B1, under `fail` and `ask`).
2026-10-25 02:32:37 -04:00
prxnvt b40fa4e065 Merge #2145 (2fb31f11, with main at 17b9f6ff) into #2152
Main refuses window.* steps in scripts, so the recorder test's script step that a macro may not
run is app.commands.
2026-10-25 02:29:03 -04:00
prxnvt 2fb31f11f2 Merge #2144 (8ea4e279, with main at 17b9f6ff) into #2145 2026-10-25 02:26:03 -04:00
prxnvt 8ea4e27918 Merge #2151 (63a29800, with main at 17b9f6ff) into #2144
Main adds UiRequest::InsertName (Paste Name, Use in Formula), which puts a name in the cell
editor; a macro step drops it, as it drops a request to start editing a cell.
2026-10-25 02:26:02 -04:00
prxnvt 63a29800a3 Merge main at 17b9f6ff into #2151 2026-10-25 02:19:57 -04:00
prxnvt b995c7c9a0 Engine: a recorded script run keeps the steps that ran
The recorder expanded an automate.runScript step from the workbook as it
stood when the recording stopped, so a script edited or deleted during the
recording changed the saved macro, or dropped the step. The run now leaves
the steps it ran with the recorder (Recorder::ran, by step index, within
the byte budget), and the conversion reads those, falling back to the
workbook only when they could not be kept.
2026-10-25 02:18:58 -04:00
prxnvt 1e34a76428 Merge #2145 (c225caf3, with #2144, #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2152
# Conflicts:
#	crates/engine/src/cmd/extra.rs
#	docs/architecture.md
2026-10-25 02:18:55 -04:00
prxnvt c225caf30a Merge #2144 (9331b05f, with #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2145 2026-10-25 02:18:21 -04:00
prxnvt 9331b05f62 Merge #2151 (9fdf03cf, with #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2144
# Conflicts:
#	crates/engine/src/lib.rs
#	docs/macros.md
2026-10-25 02:17:32 -04:00
prxnvt 9fdf03cfb5 Merge #465 (3f8e6c28, with main at 6ead1fb5, where #462 is merged) into #2151 2026-10-25 02:16:47 -04:00
aiongg 17b9f6ff01 Paste Name and Go To: follow-ups from the batch review (#2540)
* Go To: names match in any case and script, as formulas match them

* Paste Name: only the names in view are laid out

* Docs: LAMBDA recursion limit and called error names; one Page Layout view row with the side margin marked unverified

* Go To and Paste Name: cargo fmt
2026-10-25 01:24:24 -04:00
aiongg 52fa4406c1 Merge pull request #2538 from aiongg/dcc-batch
Merge 18 open PRs (tested batch)
2026-10-25 00:53:54 -04:00
prxnvt 3f8e6c2884 Engine: a JSON save refuses a macro that skipped the check, as an xlsx save does
Every path that adds a macro runs macros::check, and an xlsx save of a
macro that skipped it fails (the stash refuses the oversized item). A
JSON save wrote such a macro instead, and the next open dropped it with
a warning. json_bytes now checks each macro first, so the save fails in
either format with the check's message and the macro stays in the open
workbook. Tests that build hostile JSON files write them directly.
2026-10-25 00:27:42 -04:00
aiongg 24e49d18ad Batch: Subtotal puts summary rows below their detail in the same undo step (#2482) 2026-10-25 04:27:21 +00:00
aiongg 850dc9c772 Batch: the cell menu offers no Show Details while a PivotTable has it turned off (#2467) 2026-10-25 04:26:22 +00:00
aiongg f17179ceeb Batch: Show Details turned off says so in plain words; the option is in the command's docs (#2461) 2026-10-25 04:22:02 +00:00
aiongg 47fadb8dcf Batch: Go To lists its entries once when it opens and lays out only the rows in view (#2414) 2026-10-25 04:16:00 +00:00
prxnvt e88760b480 Engine: an item too large to read takes no place in a stash listing
stash::items capped the items at MAX_ITEMS before reading them, so one
over the size limit took a place (a readable item past the cap was then
left out with fewer than MAX_ITEMS listed) and one past the cap was
counted in `more` though it could never be listed. Items are now read
first: the first MAX_ITEMS readable ones are listed and only readable
ones past them are counted.
2026-10-25 00:10:14 -04:00
aiongg 87a649c763 Batch: Paste Name lists the names once, finds local names by hash and refuses a name it doesn't list (#2410) 2026-10-25 03:56:45 +00:00
aiongg 77b37df655 Batch: the status bar names a circular reference by its address alone (#2510) 2026-10-25 03:53:01 +00:00
aiongg ffa08d3a31 Batch: Circular References submenu quotes sheet names that need quotes (#2501) 2026-10-25 03:52:28 +00:00
aiongg 50c7ee3971 Batch: undo, redo and rollbacks list the circular references of the restored cells (#2501) 2026-10-25 03:50:42 +00:00
prxnvt e285194643 Docs: a kept macro is saved back unchanged, not byte for byte 2026-10-24 23:45:04 -04:00
prxnvt 4c11929fe2 Tests: a visible legacy name of the macro storage shape survives a save
The stash reads a legacy _sc_<key> name as data whether or not it is
hidden, so writing key macro_0 removed a person's visible _sc_macro_0
name with no word. The index allocator steps around that key as well;
this test fails without it.
2026-10-24 23:45:04 -04:00
prxnvt da439b71ed Tests: a visible clash, a newer macro and a broken input together, through both formats
One file with a visible name of the storage shape, a macro in a newer
format and one whose input declaration breaks the rules, saved and
reopened xlsx then JSON and JSON then xlsx: the visible name stays, the
newer macro comes back whole and numbered around the name, the broken
one is left out once, and the readable one survives.
2026-10-24 23:45:04 -04:00
prxnvt 3905a4318e Engine: a macro in a newer format is kept unread and saved back unchanged
A macro whose format version is above this build's was dropped on open,
so the next save removed it from the file, and the warning said only
that it couldn't be opened. It is now kept as the file held it
(Workbook::macros_unread, shared between snapshots), never read, listed
or run, and written back unchanged by JSON and xlsx saves, numbered
after the readable macros and around visible names of the storage
shape. Kept macros count toward the 1,000-macro limit after the
readable ones; one too large for an xlsx item is left out. The warning
says it is kept and saved back; damaged and over-limit macros keep
their own warning.
2026-10-24 23:45:04 -04:00
prxnvt 471a40bd72 Model: every field a macro file sets has a limit, and an input declaration is checked as a whole
check_limits capped the name, description, notes, step count, depth and
input names, and left the shortcut, permissions, output anchor, default
range, column types, command ids, Repeat ranges, If cells and values and
Stop messages bounded only by the item size; the input declaration was
stored unchecked (rows [5, 2], a cell input with cols [2, 5]).

Now each of them has a limit (docs/macros.md lists them), one-line
fields refuse control characters, and a command id is letters, digits,
. and _ (every registered id passes, tested). An If value may be null
(Empty and NotEmpty ignore it); Repeat.max stays the runner's to bound.
An input's least rows and columns can't pass its most, a cell input is
one row and one column with no header and at most one column type, and
no input lists more column types than columns. check_limits takes every
struct apart field by field, so a new field doesn't compile there until
its limit is decided. A macro from a file that breaks a rule is left
out with the usual warning, never changed to fit.
2026-10-24 23:39:14 -04:00
prxnvt ac36eee23d Engine: a JSON workbook with no sheets keeps its macros
The JSON reader replaces a workbook with an empty sheet list by a new
one before macros::load reads the file's macro entries, so they were
lost with no warning. The entries are carried across the reset. The
reset itself, which also starts the names and styles afresh, is
unchanged.
2026-10-24 23:35:32 -04:00
prxnvt 3c722f4d86 Engine: a visible name of the macro storage shape no longer blocks an xlsx save
with_names numbered macros 0..N, and stash::set_many refuses to write a
hidden name beside a visible one of the same name, so a visible
_gc_macro_0_0 (from a file, or defined after the file opened, when the
stored names are no longer in the workbook) made every xlsx save of a
workbook with macros fail. The index is GridCraft's choice: macros now
take the free indices from 0 up, skipping every key a visible
workbook-scope name of the storage shape uses (compared as the stash
reads names), in their order. No arithmetic depends on a number from
the file.
2026-10-24 23:35:03 -04:00
aiongg f65e9f6bed Merge upstream main into the batch
# Conflicts:
#	crates/ui-egui/src/formula_bar.rs
#	crates/ui-egui/src/ribbon.rs
#	crates/ui-egui/tests/it/main.rs
2026-10-25 03:34:46 +00:00
Pranav e1a12fa337 UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget (#456)
* UI: theme tokens for icon, reference and sheet-control colours; WCAG contrast test

Tokens gains the colours the chrome was hard-coding, each with a light
and a dark value: on_accent (text on an accent fill), an eight-colour
category palette (theme::Cat), the paper colour inside icons, reference
colours for the formula bar and for the sheet, the message box warning
disc, the tab bar scroll thumb, and sheet_control_* colours that follow
the sheet and so stay light in both themes.

What now comes from the tokens:
- icons: paper (white pages glared in dark mode) and the accent
  constants; icons placed on the sheet use icons::paint_on_sheet, which
  keeps the light colours;
- reference colours (editor::REF_COLORS), which were hard to read on the
  dark formula field;
- white text on the accent in OK, Solve, slicer tiles and the AutoSave
  switch, 2:1 on the dark accent;
- the comments pane (a white card with light text in dark mode), gallery
  hover and swatch outlines, the message box disc, the tab bar thumb,
  chart selection frames, and the sheet's filter and validation buttons.

A unit test (theme::contrast_tests) checks every token pair used by the
ribbon, popups, dialogs, panes, formula bar, status bar and sheet in
both themes against WCAG 2: 4.5:1 for text and 3:1 for glyphs. It caught
the dark danger red (3.5:1 on hover) and the selected header text (4.1:1
light), which are adjusted.

* UI: ribbon scroll arrows, large split button for Formulas › AutoSum

- widgets::big_split_button: the icon runs the default action, the
  label and its arrow open the menu. It returns both responses, with
  accessible names ("AutoSum" and "AutoSum options"). big_button shares
  its label layout.
- Formulas › AutoSum uses it: the icon inserts SUM as before, and the
  arrow, which used to do the same, opens the Sum / Average / Count /
  Max / Min / More Functions menu that Home › AutoSum already had
  (shared as autosum_menu). Its keytip is unchanged.
- When a tab's groups are wider than the window, arrows at the card's
  edges scroll them; before, only the mouse wheel did and nothing showed
  that the tab went on. ribbon::RibbonOverflow records the groups' and
  the card's sizes each frame.
- A test checks every tab fits the card's three rows. It found View ›
  Show stacking four checkboxes, with Headings cut off; Show is now two
  columns (Ruler, Gridlines / Formula Bar, Headings).

Tests: tests/ribbon_layout.rs (the split button's two parts, AutoSum's
icon and menu, the scroll arrows, the card height).

* UI: formula bar grows from one to five lines; code editor widget

The formula bar is a top-aligned row: the Name Box and the cancel,
enter and fx buttons stay on the first line while the field grows. Its
height is the content's laid-out line count (Alt+Enter or wrapped text)
times the font's row height, from one line (30 px, as before) to five,
then the field scrolls with the caret in view. This holds while editing
and while viewing a cell. The height eases over 120 ms when the line
count changes, and the chevron pins it at five lines (it used to give a
fixed 72 px). The in-cell editor likewise stops at five lines and
scrolls. formula_bar::BarLayout records where the parts went, for tests.

widgets::code_editor(ui, id, &mut text, Lang, max_lines) is a small
code editor for features that edit code: the monospace font, syntax
colours from Tokens::syntax (new, with light and dark values in the
contrast test) for Python (keywords, strings including triple-quoted
ones, numbers, comments and builtins) and LaTeX (commands, scripts and
comments), Tab inserting four spaces (or indenting every selected line,
Shift+Tab dedenting), Enter keeping the line's indentation (one level
more after a Python colon), and a scroll area that follows the caret.
The tokenizer and the edits are pure functions with tests, including
hostile input that must keep spans on char boundaries.

kittest (tests/formula_bar.rs) covers Alt+Enter up to eight lines with
the Name Box and buttons fixed and the caret visible, the commit,
viewing the committed cell, the chevron, the ease, the in-cell cap and
the code editor's Tab and Enter.

* UI: Shift+Tab in the code editor dedents instead of indenting

The editor consumed plain Tab before Shift+Tab, and egui's key match ignores Shift
when the pattern lacks it, so a Shift+Tab was taken as Tab and indented the line.
Shift+Tab is now consumed first; a plain Tab still inserts spaces.

* UI: the ribbon card stays inside a narrow window, so its right scroll arrow is on screen

The row above the ribbon can be wider than a narrow window, and the card took that width, so the
right scroll arrow was drawn past the window's edge and couldn't be clicked: below about 640 px on
macOS, and at 700 px with Linux's wider default fonts, where CI's ribbon test failed. The card is
now kept within the window with the same margin it has on the left.

* UI: each ribbon tab keeps a scroll offset of its own

The ribbon's scroll area used one identity for every tab, so scrolling an overflowing tab and
switching to another overflowing one hid that tab's first groups. Each tab now has its own scroll
state: a tab opened after another was scrolled starts at its left edge, and a tab keeps its offset
while others are used.

* UI: the in-cell editor keeps its whole width for the text and shows every line

Merged with main, the grid's own test asked the in-cell editor to wrap its
text at the field's full width and to show every line (the editor never
scrolls); the editor widget wrapped it inside the formula bar's scroll area,
which took the bar's width off the text (168 px tall where 156 was right),
and the grid capped it at five lines. The in-cell editor now shows the text
field on its own, through the one show_edit helper the formula bar also
uses, and grows with every line; the formula bar keeps its five lines and
its caret scroll. The branch's five-line test for the cell editor becomes a
grows-with-every-line test.

* Docs: the UI building blocks' revision rows carry the date they land

The rows said 2026-10-11, older than upstream rows above them, and the
ui-parity row said the in-cell editor is capped at five lines.

* UI: the formula bar's view of a cell starts at the cell's top

The bar's read-only view kept one scroll offset for every cell, so a cell
shown after scrolling another's text started scrolled. The offset is kept
per cell now.

* UI: the editor's field does nothing with Enter on its own

The editor handles Enter (commit) and Alt+Enter (a line break) itself, and a
message box over the editor takes Enter to close. The field under it is a
multi-line TextEdit since the bar grew, whose own return key would add a
line if it ever saw Enter; return_key(None) keeps that from depending on
where focus sits. A test shows Enter over a message box adds nothing to the
entry (focus is off the field then, so it passed before too: this is
hardening, not a repair). The doc comment split by the show_edit helper is
mended.

* Docs: ui-parity says the in-cell editor shows every line, and its revision table is whole

A sentence still said the in-cell editor stops at five lines, and a blank
line between the table's separator and its first row ended the table.

* UI: the split button's tooltip carries the shortcut hint like every ribbon button's

big_split_button built its tooltip with the bare translation, so AutoSum,
which became a split button, lost its shortcut hint. It uses tip_text now;
the split-button test hovers the button and looks for the hint.

* UI: a selection that ends at the start of a line leaves that line alone

Selecting "a\n" in "a\nb" and pressing Tab also indented b, and Shift+Tab
could take its indentation: the line after the selection was counted as
touched. It is touched only when the selection reaches into it.

* UI: the code editor applies its keys in input order, with the typing around them

The editor consumed Enter, Tab and Shift+Tab before the text field saw the
frame's events, so a frame carrying typed text followed by Enter (events
gather between repaints, and under key repeat) put the newline before the
text, and two Enters in one frame made one line. The editor now walks the
frame's events in order and applies typed text, pastes, Enter, Tab,
Shift+Tab, Backspace and Delete itself, taking them from the queue; the
first event it does not handle and everything after it stay for the field,
which runs next, so the order holds either way.

* UI: the code editor keeps the input order past the keys the text field handles

The editor applied its keys up to the first event it did not handle and
left that event and everything after it to the text field, which has
its own Tab and Enter: select all followed by Tab in one frame replaced
the code with a tab character, and since the platform sends a key event
before each text event, an Enter after ordinary typing lost its
indentation.

The editor now applies its keys up to the first event the field acts on
(a caret or selection key, a key under a command modifier, IME, a click);
that event and the run after it stay for the field; from the editor's
next key on, the keyboard events are held back under the editor's id for
its next pass, where it puts them ahead of that pass's input and splits
again. They go only to the editor they were typed into, only while it
still has the focus, and only in the pass right after; otherwise they
are dropped rather than typed into another widget. Printable key presses
do not split the stream. Tab under a command or option modifier is taken
off the queue, and Enter under Shift or Option is the editor's newline,
so the field never gets a Tab or an Enter of a focused editor.

* UI: the ribbon layout and formula bar tests join the crate's integration test binary

Since #2372 the crate's integration tests are one binary built from tests/it/, one module per file; only tests that set process-wide state stay separate. These two set none.

* UI: the formula bar's one-line height follows the font, so five lines fit on Windows

One line was a fixed 30 px and the bar grew by one row height per line.
The panel's and the field's margins take 12 px of that, and rounding to
the pixel grid up to another, so a row taller than 17 px lost the last
counted line: with the Windows UI font (17.28 px rows) five lines showed
four, and bar_grows_to_five_lines_then_scrolls_with_name_box_pinned
failed there.

formula_bar::one_line(row_h) is 30 px unless the rows need more, and
every counted line fits whatever the font. The kittest heights are
computed from it rather than from 30, and a unit test checks the
arithmetic for rows of 15.6 to 20 px.

* UI: Paste Special's OK button uses the on-accent colour

It was the one OK button still drawn white on the accent: 2.14:1 in the dark theme, where every other OK uses on_accent (7.1:1).
2026-10-24 20:54:34 -06:00
aiongg 846d5b98ee Merge storytold/gridcraft#2525: Ribbon: Insert › Column or Bar offers 100% Stacked Bar 2026-10-25 02:54:23 +00:00
aiongg 6194f8241d Merge storytold/gridcraft#2522: Charts: 100% Stacked Area draws each value as a share of its category's total
# Conflicts:
#	ROADMAP.md
2026-10-25 02:54:23 +00:00
aiongg 1e50c72698 Merge storytold/gridcraft#2488: Headers and footers: printing follows the Scale with Document and Align with Page Margins check boxes
# Conflicts:
#	docs/ui-parity.md
2026-10-25 02:54:23 +00:00
aiongg b58846a549 Merge storytold/gridcraft#2482: Outline: Settings puts summary rows above and summary columns left of their detail
# Conflicts:
#	docs/ui-parity.md
2026-10-25 02:54:23 +00:00
aiongg 1713334579 Merge storytold/gridcraft#2477: Sheet tabs: drag the handle at the scrollbar's left edge to give the tabs more or less room
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-25 02:54:22 +00:00
aiongg cb1fb89549 Merge storytold/gridcraft#2467: PivotTables: right-clicking a value cell offers Show Details in the cell menu
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/pivot.rs
#	docs/ui-parity.md
2026-10-25 02:46:38 +00:00
aiongg 75f7df9da6 Merge storytold/gridcraft#2461: PivotTables: Enable show details turns Show Details off for a PivotTable
# Conflicts:
#	ROADMAP.md
2026-10-25 02:46:16 +00:00
aiongg 8356b33c78 Merge storytold/gridcraft#2456: Fill handle: Fill Days, Fill Weekdays, Fill Months and Fill Years for a date
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-25 02:46:16 +00:00
aiongg 2741680144 Merge storytold/gridcraft#2427: Keyboard: Ctrl+Shift+U expands and collapses the formula bar instead of underlining
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-25 02:46:16 +00:00
aiongg 924d36e247 Merge storytold/gridcraft#2421: Status bar: a right-click opens a statistics menu, with Numerical Count, Minimum and Maximum added
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/tabs.rs
#	docs/ui-parity.md
2026-10-25 02:25:39 +00:00
aiongg ae88b40f58 Merge storytold/gridcraft#2414: Go To: lists the named ranges of every sheet and the references entered, and goes to several at once
# Conflicts:
#	docs/ui-parity.md
2026-10-25 02:25:15 +00:00
aiongg 0217dbdfd8 Merge storytold/gridcraft#2410: Formulas: F3 opens Paste Name, which inserts a defined name or pastes the list of names 2026-10-25 02:24:49 +00:00
aiongg 91a1d18c91 Merge storytold/gridcraft#2510: Status bar: Circular References names a cell only when a loop is on the active sheet 2026-10-25 02:24:49 +00:00
aiongg 58aa0f1df2 Merge storytold/gridcraft#2504: Circular references: Error Checking and the status bar quote sheet names that need quotes 2026-10-25 02:24:49 +00:00
aiongg 2660973286 Merge storytold/gridcraft#2501: Error Checking: its arrow opens a Circular References submenu listing a cell on each loop 2026-10-25 02:24:49 +00:00
aiongg 44cc84edf2 Merge storytold/gridcraft#1809: Calc: a LAMBDA that calls itself without end answers #NUM! instead of overflowing the stack 2026-10-25 02:24:49 +00:00
aiongg f1b06a9481 Merge storytold/gridcraft#2486: docs: Different First Page and Different Odd & Even Pages are no longer listed as missing 2026-10-25 02:24:49 +00:00
aiongg bb6f38d95d Merge storytold/gridcraft#2434: Tests: the engine's file-system tests each use their own temporary folder 2026-10-25 02:24:44 +00:00
NetterSilverandClaude Haiku 5.5 960c64c3e4 Return None from datetime_from_serial when rounding carries past 9999
Found while testing crates/core/src/date.rs:168-177

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:19:51 +00:00
dotCooCoo c44971fcd5 Ribbon: Insert › Column or Bar offers 100% Stacked Bar
The Column or Bar menu on the Insert tab listed every column and bar
type of the All Charts list except 100% Stacked Bar. It now lists it
after Stacked Bar, and choosing it runs insert.chart with type bar and
subtype stacked100.
2026-10-24 17:45:22 -07:00
NetterSilverandClaude Haiku 5.5 14e73dd0e9 Make the Row Height dialog use points like Excel
Fixes #2453

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 00:39:55 +00:00
dotCooCoo 0eb2f1f6f0 Charts: 100% Stacked Area draws each value as a share of its category's total
Insert > Line or Area and the All Charts list offer 100% Stacked Area,
and insert.chart and chart.set take it as type area with subtype
stacked100. Each series' area stacks on the ones before it as its
share of the category's total. When every series on the value axis is
100% stacked and no value is negative, the axis runs from 0% to 100%.
A blank cell counts as zero, and a category whose total is zero stays
on the baseline.

An XLSX area chart with percentStacked grouping now opens as 100%
stacked and is saved back as percentStacked. It used to open as a
stacked area and was saved as stacked.
2026-10-24 16:42:59 -07:00
Fubeo 4548bab2e3 feat(engine): open a saved workbook without recalculating it
DocState::new recalculates the whole workbook, so values saved in the file
change where nothing was edited (volatile functions, functions GridCraft
lacks, cycles). DocState::from_saved builds the dependency graph without
evaluating, so every value stays the file's until an edit reaches it.
2026-10-25 01:37:44 +02:00
Fubeo 3e6eac4222 fix(engine): editing a note or comment keeps its thread
Writing a note or comment on a cell that already has one of the same kind
replaced the whole thread: Edit Comment lost the replies, the resolved state
and the dates, and Edit Note the box read from the file. Now only the text
(and the author, when the command names one) changes, as Excel's Edit Note
does; a note written over a comment still replaces it.
2026-10-25 01:31:19 +02:00
Fubeo 85e8f7909e fix(xlsx): keep each sheet's sheetId across a rewrite
The writer numbers sheetIds from 1 in sheet order, so a rewritten workbook
changes the id of any sheet that is not part of a gapless series from 1.
Anything that recognises a sheet by its id (parts carried through as they
are, external tools) loses it. The model keeps the sheetId it read
(Sheet::file_id, PreservedSheet::sheet_id, not serialised to JSON) and the
writer writes it back; a new sheet, or a copy with a taken id, gets the first
free one.
2026-10-25 01:31:19 +02:00
dotCooCoo a2e4b2b1da Error Checking: the Circular References submenu scrolls
The submenu lists up to 100 cells. When the list was taller than the
window, the cells below its bottom edge could not be reached. The list
now sits in a scroll area 400 points high.
2026-10-24 15:48:55 -07:00
dotCooCoo a75224617b Status bar: Circular References names a cell only when a loop is on the active sheet
The status bar named the first circular reference in the list, even
when it was on another sheet. It now names the first one on the active
sheet, and shows only Circular References when every loop is on other
sheets, as Excel's status bar does.
2026-10-24 15:43:52 -07:00
dotCooCoo 1dd07b7f75 Circular references: Error Checking and the status bar quote sheet names that need quotes
formulas.errorChecking listed each circular reference as Sheet!A1 with
the sheet name as it is, so a sheet named Bob's Q1 gave Bob's Q1!B2,
which a formula does not accept as a reference. The list now quotes the
name with quote_sheet, as references are written elsewhere in the
engine ('Bob''s Q1'!B2), and the status bar's Circular References
label uses the same form.
2026-10-24 14:51:06 -07:00
dotCooCoo 71669dae6d Error Checking: its arrow opens a Circular References submenu listing a cell on each loop
Formulas > Error Checking is now a split button. The button opens the
Error Checking dialog as before, and its arrow opens a menu with Error
Checking… and Circular References. Circular References lists a cell on
each circular reference as Sheet!A1, up to 100, and picking one selects
it on its own sheet and scrolls the grid to it. With no circular
reference the item is unavailable.

The calculation kept only the loops its latest run found, so typing in
any cell outside a loop emptied the list, and the status bar's Circular
References with it, while the loop was still there. A partial
recalculation now keeps the loops among the formulas it does not
evaluate. In automatic calculation a loop leaves the list once an edit
breaks it. In manual calculation only the edited formulas run, so a
listed cell stays until it is edited or the workbook is recalculated.
A full recalculation or iterative calculation builds the list again.
2026-10-24 14:28:19 -07:00
Marco Arnulfo 0cf9476241 Shift+Arrow on a whole column or row: still follow an extension that reaches the sheet's edge itself (#2451) 2026-10-24 23:01:27 +02:00
Harrison Booth a2aa72c3cb Docs: drop the stale Options-dialog-missing note from the Backstage row 2026-10-24 15:54:20 -05:00
Marco Arnulfo a75d633d68 Shift+Arrow on a whole column or row keeps the view on the rows or columns it showed (fixes #2451) 2026-10-24 22:51:59 +02:00
Harrison Booth 3ef94601d5 Options: commit iteration fields only while iteration is on; accept the locale's decimal comma for Maximum Change
Fields still holding unconfirmed text when the checkbox turns iteration off are no longer committed,
and Maximum Change parses with the interface's decimal separator as well as a dot.
2026-10-24 15:47:17 -05:00
Harrison Booth 6bdde35133 Merge origin/main into gc-options-iteration-fields
# Conflicts:
#	ROADMAP.md
2026-10-24 15:12:26 -05:00
dotCooCoo b745c136b2 Headers and footers: printing follows the Scale with Document and Align with Page Margins check boxes
The Header & Footer tab now has the Scale with Document and Align with
Page Margins check boxes, and pageLayout.headerFooter takes
scaleWithDoc and alignWithMargins. XLSX already kept both settings,
but nothing else read them.

With Scale with Document on, header and footer text prints at the
sheet's print scale. With it off, the text keeps its point size. With
Align with Page Margins on, the left and right sections line up with
the page's side margins. With it off, they start 0.7 inch from the
paper's edges, or a quarter of the page width on paper narrower than
2.8 inches. The Page Layout view draws headers and footers and takes
clicks on them the same way. Both settings are on by default, so a
sheet printed at a scale other than 100% now prints its header and
footer text at that scale.
2026-10-24 13:01:00 -07:00
dotCooCoo 9425358faa docs: Different First Page and Different Odd & Even Pages are no longer listed as missing
The Page Layout view row in docs/ui-parity.md listed both options as
missing. The Header & Footer tab has had both check boxes since #344, and
the first page and the even pages print and show their own header and
footer. The row now says so and lists Scale with Document and Align with
Page Margins as missing instead: XLSX keeps both settings, but no control
shows them and printing does not read them.
2026-10-24 12:16:24 -07:00
dotCooCoo 118cd15d93 Outline: Settings puts summary rows above and summary columns left of their detail
Data > Outline > Settings now has Summary rows below detail and Summary
columns to right of detail. With one cleared, each group's - / + button
sits on the line before the group, its bracket runs down to the last
detail line, and Hide Detail, Show Detail, the level buttons and
Ungroup mark or clear the collapsed flag on that line. Changing a
direction moves the flags of collapsed groups to their new summary
lines.

- model: Sheet::summary_below and summary_right (on by default, also
  for older JSON).
- xlsx: read and written as sheetPr/outlinePr summaryBelow and
  summaryRight; files that set them no longer lose them on save.
- engine: data.outlineSettings {sheet?, summaryBelow?, summaryRight?},
  undoable; outline::groups_with places the summary line.
- ui: a Settings button in the Data tab's Outline group opens the
  dialog; the outline band follows the setting.
2026-10-24 11:34:35 -07:00
dotCooCoo 010eddd25e PivotTables: the cell menu computes a report once for all its cells
The cell menu checked whether to offer Show Details by computing the
whole report again for each new active cell. pivot::detail_cells now
returns every value, subtotal and grand total cell of a report from one
computation, and the menu caches that set per document, revision, sheet
and PivotTable.
2026-10-24 11:27:58 -07:00
Marco Arnulfo b5bf14db98 Trace arrows stay on the traced cell when the selection moves (part of #2475) 2026-10-24 20:08:41 +02:00
dotCooCoo 7c10db07d9 PivotTables: Show Details reports the turned-off option before an out-of-date report
With Enable show details off, Show Details on a value cell of a report whose
source has changed gave the out-of-date error. It now gives the turned-off
message, and a label still reports that it is not a value cell. The message
names pivot.options with enableShowDetails set to true, since there is no
PivotTable Options dialog.
2026-10-24 10:31:09 -07:00
dotCooCoo 3d41bdb956 Sheet tabs: drag the handle at the scrollbar's left edge to give the tabs more or less room
The sheet tabs took up to 60% of the tab bar and the horizontal
scrollbar the rest, and nothing between them could be dragged. The
gap at the scrollbar's left edge is now a handle: it shows the resize
cursor, and dragging it right gives the tabs more of the bar, until the
scrollbar is 100 px wide. After the window is narrowed, the scrollbar
is still 100 px wide. Dragging it left gives the scrollbar more, down
to the New sheet button. Room the tabs do not fill stays empty, and
New sheet stays after the last tab.

Each open workbook keeps its own share while it is open. The share is
not saved in the file, and a workbook starts at the default.
2026-10-24 10:01:58 -07:00
NetterSilverandClaude Haiku 5.5 4c0cc09505 Fix parsing of ranges whose second end repeats the sheet name
Found while testing crates/formula/src/parser.rs (reference/sheet-prefix parsing) with crates/formula/src/printer.rs

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:48:21 +00:00
dotCooCoo 28efc9dabf Docs: list the date fill modes in the ui-parity history row without table pipes 2026-10-24 09:25:36 -07:00
dotCooCoo 3fa6cb470b PivotTables: right-clicking a value cell offers Show Details in the cell menu
The cell menu of a PivotTable's value, subtotal or grand total cell now
has a Show Details item after Sort Z to A. It runs pivot.showDetails on
the active cell, which lists the cell's source records on a new sheet,
as double-clicking the cell does. Labels and headers of a current
report, and cells outside a report, do not offer it. In a report that
is out of date every cell of the report offers the item, and running it
shows the message that asks for a refresh.

Whether the menu offers the item is cached per document, revision,
sheet and active cell.
2026-10-24 09:04:33 -07:00
NetterSilverandClaude Haiku 5.5 b4487663aa Fix YEARFRAC basis 0 for February-end start and 31st end
Found while testing crates/functions/src/date.rs:443-457 (yearfrac_core, basis 0)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:10:13 +02:00
dotCooCoo 30126a709a PivotTables: Enable show details turns Show Details off for a PivotTable
A PivotTable has an Enable show details option, set with the new
pivot.options command ({pivot?, enableShowDetails?: bool}). While it is
off, pivot.showDetails and double-clicking a value cell list no records:
the double-click shows a message and does not open the cell for editing.
Labels and headers still edit. Changing the option is one undo step and
does not refresh the report. pivot.fields reports the setting.

XLSX files keep it: an off option is written as enableDrill="0" on the
pivotTableDefinition, and enableDrill is read on open (on when absent).
2026-10-24 07:46:29 -07:00
dotCooCoo 46f6157800 Merge main into fix/lambda-recursion-stack 2026-10-24 07:29:00 -07:00
dotCooCoo 2ae2329090 Docs: drop the stale missing-keys row that still listed Ctrl+6
Ctrl+6 hides and shows objects, and the row above already lists the other missing keys (Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P).
2026-10-24 07:26:48 -07:00
NetterSilverandClaude Haiku 5.5 92d2b9894f Fix IMSQRT leaving a stray real part for negative reals
Found while testing crates/functions/src/engineering.rs:713 (imsqrt, via Cx::powf at :562)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:24:48 +00:00
dotCooCoo 00edfd0690 Fill handle: Fill Days, Fill Weekdays, Fill Months and Fill Years for a date
When a fill starts from a date, the right-drag fill menu and the Auto
Fill Options button now also offer Fill Days, Fill Weekdays, Fill Months
and Fill Years. edit.autoFill takes them as the modes days, weekdays,
months and years.

A lone date steps by one unit. Two or more dates step by the difference
between the first two in that unit, or by one unit when both fall in
the same one. Fill Weekdays skips Saturday and Sunday, and a start on a
weekend moves to the next weekday first. Fill Months and Fill Years keep
the day of the month, clamped to the month's last day. A source that is
not all dates fills as a plain drag does.

Fills by months or years now keep the time of day of a date with a
time. A plain drag of dates a month apart and Series by Month or Year
used to drop it.
2026-10-24 06:56:26 -07:00
NetterSilverandClaude Haiku 5.5 86064077f1 Fix ISOWEEKNUM/WEEKNUM(…,21) for the first days of the calendar
Found while testing crates/functions/src/date.rs:123-129 (isoweek)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:04:35 +02:00
Arie NugrahaandClaude Code 276cd0fe78 web: parse ?chrome= so an embedding host can leave out the title bar and File tab
#1877 added Chrome to SheetApp for hosts that embed the sheet natively, but the web build never set it: an iframe host that owns the files still draws the title bar it duplicates, and Backstage's Open/Save As can replace the document behind the host's back. Parse the flag where the other URL flags are parsed; the UI already honours Chrome, so nothing else changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-24 13:00:37 +00:00
Dylan M. Taylor 3099df934e M7: verify Agile encryption's dataIntegrity before returning plaintext (#2425)
Agile decryption checked only the password verifier, which authenticates
the key — not the package. An attacker who could replace EncryptedPackage
ciphertext could change workbook content undetected ([MS-OFFCRYPTO]
2.3.4.14, the descriptor's dataIntegrity element).

The reader now requires the dataIntegrity pair, decrypts it with the
recovered package key, and checks HMAC(hash, hmacKey) over the whole
EncryptedPackage stream (StreamSize field included) against the stored
value before any plaintext is returned. Excel always writes the element
("SHOULD" in the spec); descriptors without it are refused as damaged
rather than accepted unauthenticated.

- crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key
  longer than the block size
- fixtures write the pair like Excel; new tests: missing/empty/ragged
  integrity fields, tampered ciphertext and descriptor, a changed
  StreamSize field
2026-10-24 07:58:13 -04:00
dotCooCoo d0fe8b6423 Tests: the engine's file-system tests each use their own temporary folder
save_fails_upfront_when_the_temp_name_is_taken,
save_keeps_the_destination_access_restrictions and
open_refuses_directories_and_unknowable_lengths in crates/engine/src/io.rs
all worked in temp_dir()/gridcraft-fs-safety and removed it at the start
and the end. cargo test runs them on parallel threads, so one test could
delete the folder while another was writing to it, and the other test
failed. Each test now uses its own folder, named with a short label and
the process id, as the gridcraft-write and gridcraft-sandbox tests in the
same file do.
2026-10-24 04:54:53 -07:00
NetterSilverandClaude Haiku 5.5 6cb3220164 Scale MINVERSE rounding cutoff to the input matrix
Found while testing crates/functions/src/math.rs:923

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:09:26 +00:00
dotCooCoo 07890b47f8 Keyboard: Ctrl+Shift+U expands and collapses the formula bar instead of underlining
Ctrl+Shift+U ran the Ctrl+U shortcut on Windows and Linux and underlined
the selection. In Excel it expands or collapses the formula bar, on
Windows and as Control+Shift+U on a Mac. It now does that on the grid
and in the cell editor or formula bar while editing, and the entry stays
open.

The new UI command view.expandFormulaBar {on?} sets the state, or
toggles it without a parameter, and returns {on}. The chevron at the end
of the formula bar is unchanged.
2026-10-24 03:55:07 -07:00
Dylan M. Taylor 10bec74810 M6: misc hardening — unbounded picture cache, stats walk, signing tmpdir, web clipboard, dash budget, fs cycle, rezip loop (#2422)
- floating_pictures: draw without retaining when eviction can't free
  enough room, so a large workbook can't pin the cache past its budget
- xtask stats: skip unreadable subtrees instead of aborting the walk
- import-cert.sh: per-run private temp dir instead of predictable
  paths in a shared root
- gridcraft-web: gate clipboard ops behind user activation
- chart raster: cap dash rendering to the stroked budget
- cli check: mark directories visited by identity on unix so hard-link
  loops can't recurse; reuse one decompression of a reused part
2026-10-24 06:33:19 -04:00
Dylan M. Taylor a005d8235f M2.9: close protection gaps a trusted script or other sheet would slip through (sec scan findings 17,20,24) (#2420)
- scripts: refuse `window.*` steps like file and link steps — activating
  another window repointed every later step at a different open document
  (finding 17).
- format: on a protected sheet, formatting may not change a cell's
  protection flags: the format-cells permission can't drop `locked` and
  then authorize content edits the password gates (finding 20).
- sheet.moveColumns: the insert/delete protection checks now carry the
  command's own `sheet` parameter, so the sheet modified is the sheet
  authorized, not whichever is active (finding 24).

Tests: unlock-via-format refused, cross-sheet move refused, window switch
refused, plus the positive controls. Engine: 462 tests pass; clippy clean.
2026-10-24 06:15:24 -04:00
Dylan M. Taylor 48d9ba264c M2.9: harden native file open/save (sec scan findings 15,22,25) (#2418)
- read_file: verify the opened file is regular and cap the bytes actually
  taken at the 2 GB limit — a workbook-named symlink to an endless device
  reported length 0 but streamed forever (finding 22).
- write_file: create the temporary file exclusively, so a planted file or
  symlink at the predictable `book.xlsx.sctmp` path makes the save fail
  instead of truncating the link's target (finding 25).
- write_file: inherit the destination's permissions, so saving an
  owner-only workbook doesn't become world-readable through the rename
  (finding 15); new files note restrictive defaults.

Tests: exclusive temp creation vs planted symlink, permissions preserved,
directory refused on open. Engine: 460 tests pass; clippy clean.
2026-10-24 05:46:35 -04:00
Dylan M. Taylor 89d8e81941 M2.9: bound print/export/autofit work in the engine (sec scan findings 18,21,27,28,30) (#2416)
- clip_html: cap wildcard/glob lookback to a 12-byte window instead of scanning
  outgoing text arbitrarily far for a semicolon (finding 18).
- print: wrap_lines binary-searches its break instead of rescanning the line
  from the start for every band (finding 21).
- autofit: per-row merge index instead of scanning every merge per row during
  autofit (finding 27) — behaviour covered by the existing autofit merge test.
- Save as Web Page: the used rectangle bounds the HTML (finding 28, #2387) —
  a tall/wide or sparse far-right sheet now errors instead of growing
  gigabytes of <td></td>.
- print: decode each embedded picture once per document instead of once per
  band that shows it (finding 30).

Engine: 459 tests pass; clippy clean.
2026-10-24 05:46:32 -04:00
Dylan M. TaylorandCodebuff 6fac430f55 M2.9: cap xlsx import work and gate hostile datetime field lengths (#2413)
Security-sweep findings from the scan at revision ac8da7d4 (storytold-
gridcraft-agent-findings.json), no longer exploitable on main at 72c1d57d.
Each change caps work or storage per sheet/part so a hostile file cannot buy
unbounded work with ordinary bytes; each has a regression test:

- finding 2 (package.rs): a part rejected by the MAX_PART size gate is
  charged its declared compressed size up front, so a loop of oversized
  parts still ends the allowance instead of re-entering the package.
- finding 4 (xml.rs): elements past the kept-node cap are consumed and
  dropped instead of kept — parse cost stays linear, memory stops growing
  with the same input (a small compressed workbook previously asked for
  gigabytes of declared-sized metadata tree).
- finding 10 (records.rs): .remaining() caches the suffix of a shared
  string's Continue parts, so crossing N Continue records is O(N) instead
  of O(N^2) in records scanned per character.
- finding 9 (xls shared formulas): the SHRFMLA master template is parsed
  once and re-emitted per dependent cell instead of decoded and reprinted
  for every referencing cell.
- findings 3, 6, 8 (sheet_read.rs): hyperlink-cell expansion, conditional-
  format kept range cells and dynamic-array (deleted-spill) checks are
  capped with one import warning each instead of the compact-workbook
  multi-gigabyte allocation or the scanned-per-cell cost.
- finding 31 (core/parse.rs): datetime recognition no longer reparses a
  prefix at every space with a time-shaped tail; an all-digit field length
  is gated before the derivation machinery runs, staying linear in the
  text.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-24 05:46:28 -04:00
dotCooCoo 7bc390d0fb Status bar: a right-click opens a statistics menu, with Numerical Count, Minimum and Maximum added
The status bar always showed Average, Count and Sum for a selection of
several cells and had no menu. A right-click on it now opens a menu with
Average, Count, Numerical Count, Minimum, Maximum and Sum, as in Excel.
Average, Count and Sum are on by default; the other three are off.

The text on the status bar can no longer be selected, so a right-click
on the mode text or a statistic opens the menu as well. The zoom and
view controls handle their own clicks as before.

The choice is a preference set with the new view.statusBar command and
kept in prefs.json. A prefs.json without it keeps the defaults.
2026-10-24 02:39:50 -07:00
NetterSilverandClaude Haiku 5.5 14415f410d Fix PERCENTRANK returning #NUM! for very large significance
Found while testing crates/functions/src/stat.rs:257-260 (trunc_digits, used by percentrank_impl)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:16:03 +02:00
Dylan M. TaylorandCodebuff f284f6a825 M2.9: budget lambda work and bound wildcard substring search (#2415)
Sweep findings from the ac8da7d4 security scan (storytold-gridcraft-agent-
findings.json), remaining on main at 72c1d57d:

- finding 14 (crates/calc/src/eval.rs:988): every lambda invocation now
  spends one charge against a per-cell work budget (16M — one per cell of
  the largest array a reference may expand to), so a hostile, deeply
  nested LAMBDA/LET formula stops at the bound a legitimately wide one
  could reach instead of growing exponentially with nesting. Exhaustion
  is #CALC!, like the existing depth cap. Metered at `call_lambda_values`
  so every path into a body is counted (direct application, MAP/REDUCE/
  SCAN/BYROW/BYCOL/MAKEARRAY, D-function criteria); the criteria
  sub-evaluator hands its spend back to the parent.
- finding 16 (crates/functions/src/criteria.rs:313): `match_from` takes a
  step budget (1<<26: an honest match against Excel's 32,767-char cell
  ceiling costs a few hundred thousand steps), and `wildcard_find` drops
  the leading star run (a no-op for a prefix match) so the first literal
  anchor skips straight to its occurrences; all suffix attempts share one
  budget. Star churn degrades to no-match instead of quadratic work.

Unit tests: mock-host exhaustion/under-budget for lambda applications
(eval::fuel_tests), nested REDUCE under budget in full recalc, wildcard
behaviour unchanged.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-24 05:07:08 -04:00
dotCooCoo 983d3d7e6a Go To: lists the named ranges of every sheet and the references entered, and goes to several at once
The Go To dialog lists the named ranges of the workbook and of every
sheet, a name scoped to another sheet as Sheet2!Name, without hidden
names, constants or formulas. After them it lists the references
entered in the dialog, newest first, and a double-click on an entry
goes there.

edit.goTo takes several names or references separated by commas
(Price, Type or B14:C22,F19:G30) and selects them all, follows a name
that refers to several areas, and goes to Sheet2!Name on Sheet2. With
record: true it keeps a reference that holds no name among the recent
ones, and with list: true it returns the names and the recent
references.
2026-10-24 01:22:19 -07:00
dotCooCoo 00b65d7e8c Merge main into fix/lambda-recursion-stack 2026-10-24 00:38:11 -07:00
dotCooCoo 3557b9f6cd Formulas: F3 opens Paste Name, which inserts a defined name or pastes the list of names
formulas.pasteName lists the names the active sheet can use (workbook
names and the sheet's own, without hidden names, in alphabetical order).
With name it puts the name at the caret of the formula being edited,
replacing selected text, or starts =Name; with list: true it writes each
name and the text of what it refers to in two columns from the active
cell. The Paste Name dialog opens from F3 when the workbook has names,
from Use in Formula's new Paste Names… item and from Alt+M, S, P. While
a dialog is open over the edit, the editor takes no keys.

Use in Formula lists the same names, and formulas.useInFormula inserts
at the caret instead of replacing the text being edited.
2026-10-23 23:47:38 -07:00
prxnvt 54bd4eca9d Engine: a run that edited the workbook before opening a dialog is recorded
The recorder left out every call that opened a dialog, so a script with an
edit before a menu-style step lost that edit from the recording. A call is
left out only when it opened a dialog and changed nothing (the dialog's
confirming call is the step); one that changed the workbook is kept.
2026-10-24 02:42:45 -04:00
prxnvt 6cd4cebfb7 Engine: Stop Action runs on its own and is not recorded
Run from inside another command, automate.stopAction rolled back that
command's own group, pulling the workbook from under the rest of its run; and
the recording buffer kept it, so a recording could replay the stop inside the
run it was part of. Nested, the command is refused and the group stays; the
recorder leaves it out (record::NOT_RECORDED).
2026-10-24 02:18:07 -04:00
prxnvt f040b4ecaa Engine: stash listings say how many items were past the cap
stash::items and stash::keys listed the first 10,000 items and let the rest
vanish without a word. They return a Listing { found, more } now, so a caller
can say what it did not list.
2026-10-24 02:18:07 -04:00
prxnvt fddf2d9a31 Merge main at 6ead1fb5 (#462 merged) into #465
# Conflicts:
#	crates/engine/src/cmd/stash.rs
2026-10-24 02:10:51 -04:00
prxnvt 95c7e70ce6 Merge #462 (3b25aa0d, with main at b8771d51) into #465
# Conflicts:
#	crates/engine/src/cmd/stash.rs
2026-10-24 01:53:52 -04:00
prxnvt 3b25aa0d16 Docs: the panic guard's note says what happens to a group the command ended
It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
2026-10-24 01:41:03 -04:00
prxnvt 4a294aefa9 Engine: the name commands use the stash's own rule for what holds its data
The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.
2026-10-24 01:41:03 -04:00
prxnvt b09a5502e3 UI: the toolbar's Undo and Redo follow the commands' own availability
The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.
2026-10-24 01:30:15 -04:00
prxnvt b44a5139ab Engine: the name commands refuse only names that hold GridCraft's data
The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.
2026-10-24 01:19:07 -04:00
prxnvt f2a7252f93 Merge main into #462 2026-10-24 01:07:18 -04:00
prxnvt 9bbba198c6 Engine: a document's maker says where its content came from
`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.

`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.
2026-10-24 01:04:51 -04:00
prxnvt 37bf09e94a Engine: queries take no copy of the undo history
`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.

The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.
2026-10-24 01:04:51 -04:00
prxnvt 4668fc30ae Engine: the name commands leave GridCraft's data names alone
`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.

Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.
2026-10-24 01:04:51 -04:00
prxnvt b90710794a Engine: list stash items in one pass over the names
Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.

`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.
2026-10-24 00:55:07 -04:00
prxnvt 0fa6e78863 Engine: stash items keep characters XML can't carry
The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.

`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.
2026-10-24 00:53:38 -04:00
prxnvt 2e72407fc6 Engine: the recorder skips a call that only opened a dialog
A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.

`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.
2026-10-24 00:51:37 -04:00
prxnvt 2b33fcf881 Engine: a failed command rolls back the undo groups it opened
A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.

`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.

`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.
2026-10-24 00:49:43 -04:00
prxnvt 359cce4bc3 Tests and docs: merge fallout from main and the lower branches
automate.allScripts reports trusted since #2178, and scripts reopened from
a file are not trusted yet, so the side-by-side test expects the flag and
compares names and steps after the round trip. cargo xtask parity gives
276 / 292 now (upstream added a command), so the ROADMAP and ui-parity
summaries say so, and this branch's revision rows (275 / 291 to 276 / 292)
sit at the top of their tables with the date they land.
2026-10-24 00:36:36 -04:00
prxnvt ee8e5d35c0 Merge #2145 (bac5b9c6, with #2144 at 2215934c, #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2152
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/macros.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-24 00:28:47 -04:00
prxnvt bac5b9c66e Merge #2144 (2215934c, with #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2145
# Conflicts:
#	crates/engine/src/tests/macros.rs
#	docs/macros.md
2026-10-24 00:10:28 -04:00
prxnvt bb3db53489 Engine: the macro recorder keeps a step's own sheet, stays bounded, and can be cancelled
Six fixes from a review of the recorder, each with a regression test (four
fail against the old recorder; the other two bound work that grew with the
input):

- A step that names another sheet with its sheet parameter (cell.set and
  about fifty other commands) was tokenized as if on the input's sheet, so a
  recorded cell.set {sheet: Two, cell: C4} ran on Two at the input's
  relative place. Such a step keeps its references, like a sheet-qualified
  one.
- Inlining recorded runs of other macros and scripts stops at the macro's
  step limit and counts the rest in dropped, instead of expanding every run
  first (a thousand runs of a long macro made millions of steps).
- macro.record {cancel: true} ends a recording without saving it: the way
  out of a recording whose macro is too large for the workbook, which could
  neither be saved nor ended.
- Formula tokenization reads each reference in the formula and matches it,
  instead of listing every column and row of the example (an example as
  large as a sheet made a million candidates per formula).
- A selection of several areas keeps its active cell as it is; a token for
  the active cell alone put it outside the areas on playback.
- Steps recorded in another workbook are counted in dropped.
2026-10-23 23:56:32 -04:00
prxnvt 2215934c9d Engine: a shift is refused when the footprint cannot follow every write
The touch log keeps at most a million writes; past that, a write that kept a
cell's value is missing from the footprint, so a shift derived from the
block of written cells could split a row the macro writes. The footprint
now says when the log filled up (logFull in macro.check), and a shift is
refused then, as it is when the macro deletes sheets or objects. Regression
test with a million-cell fill in a dry run.
2026-10-23 23:55:39 -04:00
prxnvt 879b96f858 Docs: the footprint dry run is skipped when the conflict rule is overwrite 2026-10-23 23:30:35 -04:00
prxnvt a626705c95 Engine: a dry run puts the journal back even after the journal trimmed itself
Every nested command a dry run executes is journaled, so a long macro pushes
the journal past its limit and the journal drops its oldest entries. The
restore cut the journal back to its old length, which then kept entries from
the dry run and pointed Record Actions at the wrong place. The session now
counts the entries its journal has dropped (journal_dropped) and truncates
with its size bookkeeping (truncate_journal); the restore keeps the old
length less what was dropped meanwhile and moves the recording's start back
by the same count. Regression test with 12,000 nested steps.
2026-10-23 23:30:35 -04:00
prxnvt fb4acb04a2 Engine: closing the debugged workbook ends the debugger
file.close is not an edit, so the debugger's lock let it through, and the
session kept the debugger with a token for a group that went with the
document: the next workbook could not start a debugger, and the state
reported that no document was open. close_document (also the path that
replaces an untouched blank workbook on file.open) now ends a debugger whose
workbook it closes. Regression test.
2026-10-23 23:30:14 -04:00
prxnvt 74ff8ac625 Engine: macro runs refuse a shift that moves an input, stay clean when nothing changed, take the answer however ask was given, and share one step budget
Four fixes from a review of the runner, each with a regression test that
fails without it:

- A shift inserts cells over the block of every written cell, which moves
  that block's columns (or rows) from there on. When a bound input lay in
  the way, its data moved and sheared while the run kept the old coordinates
  and reported success. The shift is now refused, naming the input and its
  place. The output is not counted: it is where the macro writes, and the
  second footprint catches data a shift moved into it.
- A dry run or a refused run left a clean workbook dirty: view.zoom,
  sheet.activate and the view modes move the saved mark to the current
  workbook on a clean document, and the rollback put the workbook back but
  not the mark. Saved keeps and restores it.
- overwrite: true answers the question however ask was given; with an
  explicit onConflict: ask the answer was ignored and the question came back
  forever.
- The dry runs and the run share one step budget as they share one clock;
  each phase counted its own 100,000 steps before. ran reports the run's
  own steps.
2026-10-23 23:13:18 -04:00
prxnvt 461395d0a0 Engine: col and cols keep the header state, so a later data takes the header off
Input.col("date").data resolved to the whole column, header included, because
every method cleared the header state. col and cols keep it; the other
methods still clear it, and only data keeps the header row for col("…").
The token test asks for D3:D11, cols(2..3).data and col(2).data.col(1).
2026-10-23 23:09:02 -04:00
prxnvt 551eda7e8e Engine: the xlsx copy gets an empty macro list, not a copy of the shared one cleared
out.macros shares its list with the workbook being saved, so clear() copied
every macro first.
2026-10-23 23:08:49 -04:00
prxnvt 43b0e39e8e Engine: set_many refuses one key given twice
Two items under one key, in any case, would leave two hidden names of one
name, of which an xlsx file keeps one, and get would mix their chunks. A
caller's mistake, refused before anything changes. Regression test.
2026-10-23 23:08:49 -04:00
prxnvt bbf36000d4 Engine: the recorder counts a dropped step without serializing it
Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.
2026-10-23 23:08:36 -04:00
prxnvt 0df540f166 Merge #2151 (44a2f4e2, with #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2144
# Conflicts:
#	docs/macros.md
2026-10-23 22:36:10 -04:00
dotCooCoo c22787e5bf Merge main into fix/lambda-recursion-stack 2026-10-23 19:32:12 -07:00
prxnvt 44a2f4e2f1 Merge #465 (673af7ec, with #462 at 712bb766 and main at 530a8cc8) into #2151
# Conflicts:
#	docs/macros.md
2026-10-23 22:08:05 -04:00
prxnvt 00b63468e6 Engine: macro tokens resolve only where they mean a reference, and refuse instead of guessing
Six fixes from a review of the resolver, each with a regression test that
fails without it:

- A bare Row or Column under a general reference key (to, from, at, area,
  source, target, destination, location) stays text: Change Spelling's
  replacement word and a pivot field's area were being rewritten into the
  current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still
  resolve there, and the bare word still resolves under cell and range keys.
- {{{{ in a string is a literal {{, so text that needs the braces can be
  written; an unknown token is still an error.
- Input.data on a range that is only a header row, and Name: of a table
  with no data rows, are refused instead of reading the header.
- below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead
  of becoming 1.
- A defined name on a sheet whose name holds a dollar sign resolves: the
  dollar anchors are stripped from the reference part only.
- quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which
  quotes a sheet named like a cell reference, an R1C1 token or a boolean and
  leaves a period unquoted.

docs/macros.md says which keys take a bare token and how to write a literal
brace.
2026-10-23 22:02:45 -04:00
prxnvt 673af7ecdc Merge #462 (712bb766) into #465 2026-10-23 22:00:39 -04:00
prxnvt 4672b6da51 Model: an input name that reads as a cell reference is refused
A macro could declare an input called Q1, AB12 or Sum1, and a step written
with plain A1 text, as a recorded one is, would then resolve to the input's
range instead of the cell. check_limits refuses such names; the docs say so.
Regression test.
2026-10-23 21:48:53 -04:00
prxnvt 712bb766ec Engine: a grouped action that panics is rolled back before the panic goes on
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
2026-10-23 21:46:25 -04:00
prxnvt 8219c18b92 Engine: macro storage shares the list, saves in one pass, keeps unknown names, checks once
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
  workbook copy made for a cell edit or an undo snapshot holds the same list,
  and the undo budget charges a list once a snapshot owns its own copy.
  Before, every mutating command copied every step of every macro, and a
  hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
  pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
  kept on open and on save, as a visible one is, instead of being dropped
  without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
  file, built in code): the model's limits and the xlsx size limit.

Regression tests for each.
2026-10-23 21:45:54 -04:00
prxnvt 259b6e67e8 Model: Repeat and If nest four deep, as the limit says
The depth check refused any step list at the fifth level, so a fourth Repeat
or If could not even hold an empty body: three levels nested, not the four
MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If
steps themselves, in the model's limits and in the parser's pass over the
JSON. The test asks for four and refuses a fifth.
2026-10-23 21:45:54 -04:00
prxnvt 7ee9d77483 Docs: list every paste that carries a file's trust
The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.
2026-10-23 21:36:09 -04:00
prxnvt 3ca1252571 Engine: a group a panicking command ended keeps its step
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
2026-10-23 21:36:09 -04:00
prxnvt 378ce3a5a0 Docs: the macro page describes this version
The inputs paragraph described how a run binds inputs and the message it
shows, which the runner adds in a later pull request. It now says what the
declaration is for and that this version stores and checks it.
2026-10-23 21:28:05 -04:00
prxnvt cef58da39e Merge #462 (d7c3c183, with main at 530a8cc8) into #465 2026-10-23 21:25:35 -04:00
prxnvt d7c3c18345 Engine: the panic guard restores entries a nested Undo removed
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
2026-10-23 20:47:40 -04:00
prxnvt 8440427c53 Tests: a linked paste of column widths or notes copies its metadata and no cell
The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.
2026-10-23 20:15:05 -04:00
prxnvt d1e51a4af4 Engine: a later stash chunk on its own is not a listed item
stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.
2026-10-23 20:08:40 -04:00
prxnvt 456cf73886 Engine: a headless call that fails for want of a file is not recorded
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
2026-10-23 20:08:40 -04:00
prxnvt a3b5f8e45c Engine: a command that panics leaves the undo history as it found it
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
2026-10-23 20:08:40 -04:00
prxnvt 6b7c48f119 Engine: a stash chunk number with leading zeros names someone else's name
`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.
2026-10-23 20:01:21 -04:00
prxnvt 66e69cb946 Engine: a linked paste carries the file's trust whatever it pastes
Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.
2026-10-23 20:01:21 -04:00
Harrison Booth a80c7ff570 i18n: Japanese labels for the iteration settings live in ja.rs
The merge from main moved the Japanese catalog into i18n/ja.rs, but the
feature's old table stayed in i18n.rs and was never used, which failed
clippy's dead-code lint. Drop the table and add the four new labels and
messages to ja.rs.
2026-10-23 16:43:24 -05:00
Harrison Booth 1062604fee i18n: Russian Maximum Change reads as a change limit
The Russian labels for Maximum Change said relative error, which is a
different quantity. They now say maximum change.
2026-10-23 16:32:50 -05:00
Harrison Booth ac890b839a Merge origin/main into gc-options-iteration-fields
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/tests/it/file_menu.rs
#	docs/function-parity.md
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-23 16:28:15 -05:00
prxnvt 7e383f460a Docs: undo groups at a command's boundaries, and the bound on steps made inside a command 2026-10-23 16:36:52 -04:00
prxnvt 080b6d993b Engine: a grouped action that closes its document succeeds
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
2026-10-23 16:36:34 -04:00
prxnvt 6ab8d97118 Engine: the undo steps made inside a command are bounded while it runs
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.

Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
2026-10-23 16:35:45 -04:00
prxnvt 114088d589 Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.

A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.

The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
2026-10-23 16:33:42 -04:00
prxnvt 5ca886507c Engine: writing a stash item checks for a clashing visible name in one pass over the names
The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.
2026-10-23 16:21:48 -04:00
prxnvt 524826687c Docs: the architecture revision row carries the date the change lands; document trust beside script trust
The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).
2026-10-23 16:17:57 -04:00
prxnvt a8e8bf851e Engine: the recording buffer is bounded in bytes and keeps the start of a recording
The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.
2026-10-23 16:17:57 -04:00
prxnvt 18ec913c4e Engine: a command keeps its group's name only for a step that is exactly the group's
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.

The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
2026-10-23 16:17:57 -04:00
prxnvt 602d4450a2 Merge main into #462 2026-10-23 16:10:43 -04:00
dotCooCoo 450f457535 Calc: a formula calculated inside another one shares its stack budget
A cell that another cell's formula calculates on demand got its own
evaluator, which started a new 896 KiB stack budget wherever it was
called. Two cells that each count down from 131 with a LAMBDA, one read
at the deepest call of the other, overflowed a 1 MiB thread.

The budget of a formula calculated on demand is now counted from where
the outermost formula started. When it runs out there, the formula is
calculated again from the top, as a formula needed at the nesting limit
is, so its value does not depend on the order cells are calculated in.
2026-10-22 11:06:01 -07:00
dotCooCoo a0fa6c6e6e Merge main into fix/lambda-recursion-stack 2026-10-22 10:57:09 -07:00
vangel76 1da640abcc Merge upstream/main into fix/control-auth-777 2026-10-22 19:32:43 +02:00
aiongg 2ed3df2bd6 G4: two windows on one workbook without copying it; Arrange All
- The workbook's active sheet is an atomic set in place (Workbook::active_sheet /
  set_active_sheet), so a window swap or sheet switch never clones the
  Arc<Workbook> or marks it dirty. Selections already live in DocState and the
  window views. Save records the sheet of the most recently active window.
- A dialog taken out of its window while it runs goes back to that window when
  a command run from it switched windows (dialogs::show, ui.dialog.confirm).
- view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?}
  returns the visible windows, active first; the desktop moves and sizes the
  native windows over the main window's monitor. New Window and Arrange All
  buttons on the View ribbon; Arrange Windows dialog.
- Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP.
2026-10-22 16:50:10 +00:00
aiongg d4db58cde4 G3: feedback from a command that switched windows shows in the window it left active
after_engine, show_open_warnings and alert bring the per-window state in line
with the engine's active window first, so a notice, message or toast from
opening a workbook (also by a script on the session) lands in the opened
workbook's window instead of being parked with the previous one.
2026-10-22 16:29:24 +00:00
aiongg 5bd5d241a0 G3: a native window per workbook window
Each engine window is drawn in its own native window through craft-window's
immediate-viewport driver (desktop only; the web app keeps one window). The
root viewport shows one window and adopts another when its window closes.

- WindowUi: the cell draft, text-box draft, dialog, Backstage, message,
  toast, notice, grid state, name box, key tips, selected chart, Tab run and
  edit mode are per window. SheetApp's fields hold the active window's; the
  others are parked and swapped in when a window is drawn or activated, and
  after any command that switched windows.
- Session::show_window makes a window current for drawing without counting
  as an activation.
- The ribbon in each window acts on it; menu bar commands and scripts go to
  the window focused last. Leaving a window enters its draft as Enter would.
- A window's close button runs window.close: one of several windows on a
  workbook closes without asking, a workbook's last asks when unsaved.
  SheetApp::request_quit (macOS Quit item, Dock quit) asks about each unsaved
  workbook in its window; Cancel stops. Save Changes answers moved to
  SheetApp::answer_save_changes.
- The last window closed quits on Windows and Linux; macOS hides the root
  and keeps running until New or Open.
- Fonts are bound for every open workbook, since each may be showing.

craft-window is pinned to the aiongg/craft-libs window-list branch until it
merges into storytold/craft-libs.
2026-10-22 16:21:59 +00:00
aiongg 45d1d1acc0 G2: windows on workbooks in the engine; New Window shares the workbook
Session keeps a window list (stable ids, per-window selection, active sheet
and per-sheet selections swapped into the workbook without dirtying it)
in place of the index-based hidden list and the copying New Window.
Titles read Name:1 / Name:2 for a workbook with several windows.
File > Close closes one window of several; window.close, window.list and
window ids for window.activate, view.switchWindows, view.hideWindow and
view.unhideWindow. Control channel and MCP tools take window/doc ids;
scroll positions and cell drafts belong to their window.
2026-10-22 14:50:53 +00:00
prxnvt 67fa9f7b4e Merge main into #2152
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-22 02:39:09 -04:00
prxnvt bca1483e2e Merge main into #2145 2026-10-22 02:37:04 -04:00
prxnvt 38ad2dba83 Merge main into #2144
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-22 02:37:04 -04:00
prxnvt 3eed81318f Merge main into #2151 2026-10-22 02:36:09 -04:00
prxnvt faff24f3aa Merge main into #465
# Conflicts:
#	crates/engine/src/io.rs
2026-10-22 02:36:09 -04:00
prxnvt 66f7993ba5 Merge main into #462
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	docs/architecture.md
2026-10-22 02:30:25 -04:00
prxnvt bbf1bc441b Engine: stopping a recording waits while a macro in its workbook is being debugged
Stopping a recording saved the macro with a nested macro.save while a debugged run's undo group could be open on the same workbook. Stopping the run without keeping its changes then deleted the macro that macro.record had reported as saved. Stopping now fails with "Finish or stop the macro first" under macro.record before anything changes, and the recording goes on, as when a save fails for another reason, so stopping again after the run ends saves it.
2026-10-22 02:26:33 -04:00
prxnvt ea691f703c Engine: a stopped recording reports the steps cut at the macro step limit in dropped
A recording keeps up to 100,000 commands, and inlined macro and script runs can add more, but a macro holds at most 10,000 steps. Stopping truncated the converted steps to that limit without saying so: dropped counted only the recorder's own overflow, so a 10,005-step recording saved a 10,000-step macro and reported nothing missing. The steps cut at the macro limit are now added to dropped.
2026-10-22 02:26:33 -04:00
prxnvt 3f5f6ea4f1 Engine: a name given when a recording stops never replaces an existing macro
Stopping a recording with a name that a macro already has (in any case) saved over that macro's
steps, inputs and description, with only Undo to get them back. A taken name now gets a suffix
("Tidy (2)"), and the save passes replace: false as a backstop.
2026-10-22 02:26:33 -04:00
prxnvt 20eb282e75 Docs: catalog count 275 / 291 in the roadmap and parity summaries
The catalog went from 274 / 290 to 275 / 291 with View › Macros › Record Macro, and
parity-checklist.md says so, but the ROADMAP.md headline, ui-parity.md and target-app-parity.md
still said 274 / 290 (and 290 catalog ids). They now match, with revision rows.
2026-10-22 02:26:33 -04:00
prxnvt 6a004bf610 Engine: stopping Record Actions waits while a macro in its workbook is being debugged
Stopping Record Actions saves a script into the workbook, which the debugger lock refuses while a
macro there is paused. The refusal came after Record Actions had taken its recording, so the
recording was lost. Stopping is now refused before anything changes, and stopping again after
the run saves the script.
2026-10-22 02:26:32 -04:00
prxnvt c0079aabe9 Docs: a failed macro run rolls back workbook changes; a file it already wrote stays
The intro said a macro run rolls everything back on an error, but a step that exports or prints to a path writes the file before a later step can fail, and the Running section says that file stays. The intro now limits the guarantee to workbook changes.
2026-10-22 02:26:32 -04:00
prxnvt 045cf428f5 Engine: a column header made by a formula matches its token while formulas are shown
`col("Amount")` read the header through the cell's display text, which is the formula while
Show Formulas is on, so a header such as ="Amo"&"unt" matched nothing. The header is now the
cell's value as formatted, whatever the sheet shows.
2026-10-22 02:26:32 -04:00
prxnvt 6312c7cb40 Engine: only hidden names are read as macro storage
A visible workbook-scope name such as `_gc_macro_0_0` matched the storage key and was read as a
macro and removed when the workbook opened. Macro storage is written hidden, so only hidden names
are read back; a person's own name of that shape stays a name.
2026-10-22 02:26:32 -04:00
prxnvt 8797b7183c Engine: stopping a macro recording saves it in the workbook where recording started
Stopping a recording converted and saved it against the active workbook. For example, starting a
recording in Book1 and switching to Book2 before stopping saved the macro into Book2, inferred the
input's header and column types from Book2's cells and looked up inlined macros and scripts
there. The recording is now converted and saved against the workbook where it started, and the
active workbook stays active. If that workbook was closed, stopping returns an error and ends the
recording.
2026-10-22 02:26:32 -04:00
prxnvt f553619b33 Engine: the macro debugger lock also refuses edits that another command runs
Session::execute checked the debugger lock only for top-level undoable commands. A command that is not undoable could run edits as nested commands and skip it: stopping Record Actions while a macro in the same workbook was being debugged saved the script inside the run's undo group, and stopping the run without keeping its changes deleted the script again. The lock now applies at every depth, and only the debugged run's own steps go ahead.
2026-10-22 02:26:32 -04:00
prxnvt f1a46a2b53 Docs: the macro policy summary lists the file commands that do run
The policy summary said file commands never run in a macro, but File >
Properties runs, and exports and printing run: without a path they only
produce bytes, and with one they need the permission and approval. The
summary now names what is denied and what runs, and the paragraph is
wrapped to the file's line length.
2026-10-22 02:26:32 -04:00
prxnvt 0ea5d3ee33 Engine: a Name: token reaches a defined name that holds periods
`Name:` read a name only up to the first period, so `Name:Sales.Q1` looked up `Sales` and
treated `Q1` as a method, and a workbook name called Sales.Q1 could not be used in a macro,
alone or as `Name:Sales.Q1.cell(1,1)`.

The name now runs through periods, and the longest defined name or table that exists and ends
at a period (or the end) is used; what follows is read as methods. So `Name:Data.row` is a
name called Data.row, and `Name:Sales.offset(1,0)` still moves Sales. When no name matches,
the error names the text before the first method, and a bad method after a dotted name is
still refused. Tests cover direct resolution, reference parameters, `$ref` and `{{...}}`,
with and without a chained method.
2026-10-22 02:26:32 -04:00
prxnvt 3c74dfcbb0 Engine: renaming macros with clashing names on open takes near-linear work
Opening a workbook gave each clashing macro name a suffix by trying " (2)", " (3)"... in turn,
and each try scanned every macro accepted so far. A file with 1,000 macros of the same
255-character name made about 300 million name comparisons.

Load now keeps a set of the names in use (ASCII case-insensitive, as find compares them) and,
for each shortened name and suffix width, the next suffix to try, so each suffix is looked up
about once. The result is unchanged: the smallest free suffix, within the name length limit,
and the renamed macro still passes the final checks. unique_name keeps its signature. A test
opens, saves and reopens 1,000 same-name macros in JSON and xlsx and keeps them all, and
another counts lookups instead of timing them.
2026-10-22 02:26:32 -04:00
prxnvt efce63f75f Engine: macro recorder applies the macro policy to the steps of a recorded script run
An Automate script run while recording was replaced by all of its steps except automate.* ones,
without the policy check that top-level commands get. For example, a script that runs
window.activate or macro.list produced a recorded macro that the runner refused on playback. The
steps of a recorded script run, and of an inlined macro run, now go through the same policy as
top-level commands; refused ones are left out and listed as skipped.
2026-10-22 02:26:32 -04:00
prxnvt 62cececa63 Docs: debugging macros 2026-10-22 02:26:32 -04:00
prxnvt 06bbcb504a Engine: macro approvals are keyed on the macro's canonical JSON, not a 64-bit hash
Approvals were stored as (document, FNV-1a hash of the macro's JSON).
FNV-1a is not collision resistant, so a crafted workbook could hold a
harmless macro and a different one with the same hash, and approving
the first would let the second run without asking.

The key is now the macro's canonical JSON text itself (object keys
sorted), so two different macros never share an approval.
macros::hash is replaced by macros::fingerprint.
2026-10-22 02:26:32 -04:00
prxnvt 77150be455 Engine: col("Header") is refused on a range without a header row
col("...") always matched against the first row of the range, whatever
its header flag. On an input declared without a header row,
Input.col("Amount") silently picked the first column whose first data
cell read "Amount". After .data, the natural Input.data.col("Amount")
failed, because the first row was then a data row. The header row is
now carried through data, and col("...") is refused with a message when
there is none (an input without a header, a Name: base, or after any
other method).
2026-10-22 02:26:32 -04:00
prxnvt 7d31c5a4ed Engine: a macro in a newer format is dropped with a warning, not saved back as version 1
Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde
dropped the fields this build doesn't know, and the next save wrote the reduced macro back
with `v: 2`, so a newer build would read it as a complete version 2 macro.

A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks
the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is
left out with the usual single warning (which now names a newer format), and the rest of the
workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as
controls.
2026-10-22 02:26:32 -04:00
prxnvt 3f9223c85f Engine: macro recorder inlines a recorded macro run only when its steps stand alone
A macro run while recording was replaced by every command in it, including both branches of an
If and the body of a Repeat, whenever it had no inputs. For example, recording a run of a macro
with an If played back both the then and the else commands without a condition, and commands
using Row, Column or Output failed on playback. The run is now inlined only when the macro has no
inputs, no output and only plain commands (its disabled steps are left out); otherwise it is left
out and listed as skipped.
2026-10-22 02:26:32 -04:00
prxnvt ccea7a8db3 Engine: macro debugger reports a step that panics as the step's failure
When a debugged step panicked, the nested execute rolled the run back and dropped the debugger
before advance could reattach the machine, so the caller got "No macro is being debugged"
instead of the failed step. For example, stepping into a step whose command panics lost the
step path and the panic message. advance now sees that the run was already rolled back and
returns the usual failed result with the step error.
2026-10-22 02:26:32 -04:00
prxnvt b51d588cac Engine: macros can't open, hide or show windows
The policy denies window.* commands, but the View tab's window commands
are view.*: a macro could run view.newWindow, which adds a window that
the run's rollback doesn't remove, or view.hideWindow and
view.unhideWindow, which change the session's hidden windows.

The policy now denies those three. Arrange All changes nothing in the
engine and Switch Windows only lists windows, so both stay allowed.
2026-10-22 02:26:32 -04:00
prxnvt ac7c1ae639 Engine: a reference on a sheet named like a token stays a reference
is_token looked only at the leading identifier, so on a workbook with a
sheet called Output (or Row, Column, or the name of a declared input),
"Output!A1:B2" was read as the Output token, and the step failed with
"the macro has no output anchor" or "unexpected '!A1:B2'". This hit
reference keys, $ref and {{...}} alike, including references the
recorder writes for other sheets. A base followed by '!' is now a sheet
name, and the text resolves as a plain reference.
2026-10-22 02:26:32 -04:00
prxnvt ee7040a340 Docs: macro format and storage 2026-10-22 02:26:32 -04:00
prxnvt c6dda8562f Engine: macro recorder keeps references on other sheets as they are
The recorder made every unqualified reference relative to the example input, whatever sheet the
step ran on. For example, starting a recording on Sheet1 with B2:C5 selected, activating Sheet2
and setting C4 recorded Input.cell(3,2), so playback wrote Sheet1 instead; a formula entered on
Sheet2 was changed to refer to Sheet1's cells. Each step now keeps the sheet it ran on, and only
references on the input's sheet become tokens.
2026-10-22 02:26:32 -04:00
prxnvt 45e580ad91 Engine: macro.debug steps through a macro as one undo step
macro.debug runs a macro one step at a time. start checks the macro as
macro.run does (policy, approval, inputs, footprint and output rule)
and pauses before the first step; step runs one step; continue runs to
the next breakpoint; runTo runs to a chosen step path, stopping at
breakpoints on the way; stop ends the run and keeps its changes or
rolls them back; state reports where it is. Breakpoints are the steps'
own flags plus a list of paths given with any action (breakpointsOnly
makes the list replace the flags), and they stop in every pass of a
Repeat.

The run lives on the session between calls, inside an undo group that
spans them (group_begin and group_end), so a debugged run is one undo
step "Run Macro: <name>", as a normal run is. A failing step ends the
run and rolls it back; so does a Stop step that asks for a rollback.
While a macro is being debugged, edits of the workbooks the run touched
(its own, and any other a step changed) are refused at the top level
with "Finish or stop the macro first"; other workbooks stay editable,
and selecting and queries still work. Every answer gives the next
step's path, kind, command, parameters as they would run now (tokens
resolved), the Repeat pass, the parameters of the step that just ran,
the steps executed and the cells changed so far. Each action gets its
own time limit.

If a command panics while a step runs, the session's panic guard rolls
the debugged run back as well, so its undo group never stays open.
2026-10-22 02:26:32 -04:00
prxnvt bf456c9ff8 Engine: a macro's footprint dry runs keep to the run's timeLimit
footprint always ran its dry run with the default 60 second limit, so
macro.run with timeLimit 5 could run for over two minutes (a dry run,
then another after a shift) and fail with "ran longer than 60 seconds".

footprint now takes the run's clock and limit. make_way starts the
run's clock before the dry runs, so the checks and the run share one
limit, and macro.check takes timeLimit too.
2026-10-22 02:26:32 -04:00
prxnvt 7a0f591712 Docs: macro tokens 2026-10-22 02:26:32 -04:00
prxnvt 709a995290 Model: one damaged macro no longer stops a JSON workbook opening
Workbook::macros was read straight into typed macros, so one entry this
build couldn't read (a step type from a later build, an unknown
onConflict value, an entry that isn't an object) failed the whole file
with "not a GridCraft JSON workbook". The field is now read as raw
entries into Workbook::macros_read, and macros::load reads each one
with parse_macro, as for xlsx: short-form steps are accepted, the depth
limit is checked before deserializing, and a bad entry is dropped with
the same warning. Only the first 1,000 entries are read, as in xlsx.
2026-10-22 02:26:32 -04:00
prxnvt 252ffa95b6 Engine: macro recorder keeps a recording when saving it fails
Stopping a recording took the recorded commands and where recording started before saving the
macro, so a failed save threw both away. For example, stopping with a name that holds a control
character, or in a workbook at its macro limit, ended the recording, and stopping again with a
good name found nothing to save. A failed save now puts the recording back, so stopping again
saves what was recorded.
2026-10-22 02:26:32 -04:00
prxnvt 4c6962a97c Engine: a partial macro footprint no longer clears later writes; the run checks each step
The footprint dry run stops at an approved file step and at Solver, but
make_way took a partial footprint with no conflicts as proof that the
run was safe, also after a shift. A macro that exported a CSV and then
set B1 ran under onConflict fail or ask and overwrote B1 without asking.

When the footprint is partial and the rule is not overwrite, the run's
machine now checks every step as it runs: a step that writes over
cells, sheets or objects that held data when the run started stops the
run and rolls it back, with a confirmation under ask and an error
otherwise. make_way now returns the machine for the run along with
whether it shifted.
2026-10-22 02:26:32 -04:00
prxnvt 3bc1d91636 Engine: macro tokens, resolved to A1 references when a step runs
Step parameters can name where they act through tokens that are worked
out when the step runs, so a macro made on one block of data works on
another: a declared input (Input, or any other input's name), Output
(the output rule's anchor), Row or Column (the current item of the
Repeat step around the step) and Name:Total (a defined name or a
table's data). Methods narrow or move the range: col(n) or
col("Header"), row(n), cols(a..b), rows(2..), cell(r,c) relative to the
top-left cell, offset(r,c), resize(h,w), data (without the header row),
below(n) and right(n). Methods chain.

A token is written as {"$ref": "..."} anywhere, as the whole string of
a reference parameter (cell, range, target, keys ending in Cell or Range
and similar), or as {{...}} inside any string, which is how a formula
refers to its input. Other text parameters are never read as tokens, so
a cell can still hold the word Row and a chart can still be of type
column.

Every token becomes A1 text, sheet-qualified when it is on another
sheet, before the command runs, so no existing command changes. Plain A1
references in a step stay absolute. A reference that runs past the edge
of the sheet is reported apart from other mistakes, so the footprint
check can tell the two apart.
2026-10-22 02:26:32 -04:00
prxnvt 4966b9c86d Engine: opening a file reads its hidden macro names in one bounded pass
load listed every _gc_macro_ item, then read each one with a scan of
all the workbook's names, and removed each with another full scan.
Damaged items didn't count toward the macro limit, so a file with
20,000 damaged items took 20,000 scans of 20,000 names (seconds, and
far more for a larger file). The names are now taken out of the
workbook and grouped by item in one pass; only the first 1,000 items
are read, and the rest are counted in the warning without being read.
2026-10-22 02:26:32 -04:00
prxnvt 749558f978 Docs: recording macros and the management commands; roadmap, architecture and parity 2026-10-22 02:26:32 -04:00
prxnvt d305566e57 Docs: running and checking macros 2026-10-22 02:26:32 -04:00
prxnvt 7011395fcf Engine: a macro renamed on a name clash keeps its name within the length limit
When a file held two macros with the same name, the second was renamed
"name (2)" without room for the suffix. Two macros with the same
255-character name both opened, but the second name grew to 259
characters, was saved that way, and the next open dropped that macro as
over the limits. unique_name now shortens the name to make room for the
suffix, and load checks the macro again after renaming it.
2026-10-22 02:26:32 -04:00
prxnvt b2740bdf33 Engine: macro recorder and macro.list, get, save, delete, rename, record
Commands: macro.list, macro.get, macro.save (a macro object or its
fields; replace defaults to true), macro.delete, macro.rename and
macro.record, beside macro.run, macro.check and macro.debug. Saving,
renaming and deleting are undoable. The catalog gains View › Macros ›
Record Macro (parity 275 / 291).

The recorder builds on the recording buffer of top-level commands, so
nested commands are never recorded twice and trimming the journal loses
nothing. When it stops, commands a macro may not run are left out and
listed; a macro run while recording is replaced by its steps when it
has no inputs, and an Automate script run by the script's steps;
sheet.activate and selection.set steps are added where the sheet or
selection changed between commands without one, and runs of them
collapse to the last. With relative references (the default) the
selection when recording started is the example input: references in
and near it become tokens (Input.col(2), Input.rows(2..),
Input.col(2).below, Input.row(4).right, Input.cell(r,c)), exact
references in formulas become {{...}}, and the input's width, header
and column types are inferred from the example. A recording replayed on
a longer block of data writes in the matching places.

A toggle such as Bold is recorded with the state it set ("on": true),
as Excel records Bold = True, so a replay sets it instead of flipping
it again.

Automate scripts are unchanged: automate.* keep their commands,
parameters, storage and results, and View › Macros still lists the
scripts and a preserved VBA project.
2026-10-22 02:26:32 -04:00
prxnvt 23c0107511 Engine: macro policy checks a step's parameters after tokens resolve; any path counts as file access
A path given as a token (`{"$ref": "Input"}` or `{{Input}}`) became a file name only when the
step ran, after the policy had passed it, so a macro without permissions or approval could write
a file, even from macro.check. Exports also wrote to blank paths the policy ignored. Any `path`
other than null now needs the permission and approval, and the runner checks the policy again on
the resolved parameters before the command runs.
2026-10-22 02:26:32 -04:00
prxnvt 20d2a3b998 Model: a macro name with control characters is refused on every path
Only parse_macro refused a name holding a line break or other control
character. Macros that came from the macros field of a JSON workbook
were checked with check_limits alone, so a file could bring in a macro
named "a\nb" that macro.save and macro.rename would refuse. The check
now lives in Macro::check_limits, so every path applies it, and such a
macro is dropped with the usual warning when the file is opened.
2026-10-22 02:26:32 -04:00
prxnvt 54c08645b9 Engine: macro.run and macro.check: input checks, footprint dry run, policy
macro.run runs a macro as one undo step ("Run Macro: <name>"); an error
at any step rolls everything back and names the step and its path (3,
2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts:

- Policy (macro_policy). File, app, window, automate and macro commands
  and Undo and Redo never run in a macro. Reading or writing files
  (data.getData, data.fromTextCsv, insert.picture, Page Layout
  Background, or an export with a path) needs the command in the
  macro's permissions and the person's approval. A macro in a workbook
  opened from a file asks before it runs. Approval holds for the
  session until the macro's content changes; it is asked for with the
  new EngineError::Approve and given with approve: true. A step that
  would open a dialog or a web page fails instead.
- Inputs. Each declared input is bound (given, its default, or the
  selection for the first one), whole columns shrink to the used range,
  and the shape and column types are checked with messages that name
  the cells: "Input column 2 (Amount) must hold numbers; C4 holds text".
- Footprint. Unless the macro may overwrite, it first runs in dry-run
  mode on the real workbook inside an undo group that is rolled back,
  with the session's clipboard, journal, windows and the document's
  trust put back. CellStore::diff on every sheet, plus the cells
  commands wrote (DocState::touched), sheets and objects, gives what it
  would write outside its inputs; cells that only recalculated do not
  count. Writing over data, deleting a sheet or deleting an object is a
  conflict: ask (the new EngineError::Confirm, so programmatic runs fail
  unless they pass onConflict or overwrite), fail, overwrite, or shift
  the data down or right with Insert Cells and check again. A step that
  would fail, for example past the edge of the sheet, fails the run
  before anything changes. File steps and Solver stop the dry run early
  and are reported.
- Steps run on a machine with an explicit frame stack: Repeat over the
  rows or columns of a range with Row or Column bound, If on one cell
  with Excel's comparisons, and Stop, which keeps the changes or rolls
  them back. A run executes at most 100,000 steps within a time limit
  (60 s by default, timeLimit to change; the web build has no clock, so
  only the step limit applies there).

macro.check reports the bound inputs, the output anchor, the approval
needed, the footprint and the conflicts without changing anything;
nothing from an unapproved macro runs, not even as a dry run.
2026-10-22 02:26:32 -04:00
prxnvt a4b4872602 Model: macros as typed command lists, saved in the workbook
A macro is plain data in the model (model/src/macros.rs): a name, a
description, an optional shortcut, declared inputs (shape, header,
column types, non-empty, default reference), steps, an output rule
(anchor and what to do on a conflict) and the commands it may run that
read or write files. A step runs a command (id and params) or is one of
three bounded control steps: Repeat over the rows or columns of a range,
If on one cell's value, and Stop with a message. Every step carries
enabled, breakpoint and note flags. These are GridCraft macros, not VBA;
a VBA project in a file stays preserved and is never run.

Workbook::macros holds them. The JSON format keeps the field. In xlsx,
io::save_bytes writes each macro as one chunked hidden-name item,
_gc_macro_<n>, and io::open_bytes takes the items back out of the names,
so the names never linger in the open document.

Limits are checked whenever a macro is read from a file or a command:
at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep,
names and notes of bounded length, and at most 4,194,304 characters of
JSON, so that saving a workbook never fails because of a macro; nesting
is checked before the steps are deserialized. What is over or damaged
is dropped with one warning, and clashing names get a suffix.

Automate scripts keep their own storage (_sc_scripts) and are neither
read nor changed by this.
2026-10-22 02:26:32 -04:00
prxnvt 471114e296 Engine: a visible name of the item shape is not workbook data
The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.
2026-10-22 02:24:55 -04:00
prxnvt 2287f63a95 Engine: a group rolled back inside a command keeps the redo history
A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.
2026-10-22 02:00:54 -04:00
prxnvt eb3eaa052d Engine: a command keeps its group's name only for a group it ended itself
The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.

The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.
2026-10-22 01:36:01 -04:00
aiongg 4bafd4e8ee Docs: Switch Windows and drafts kept in their workbook (UI parity, progress log) 2026-10-22 05:12:23 +00:00
aiongg a0d54b49d4 A refused cell draft stops the command; an untouched F2 draft changes nothing
- File and window commands stop when the workbook refuses the cell or
  text-box draft (validation Stop, bad formula, protected text box): the
  draft stays open in its cell with the reason shown, to fix or Esc. The
  per-frame backstop brings a refused draft's workbook back to the front.
- An F2 draft committed with its text unchanged writes nothing: no undo
  step, and the workbook stays clean through Save, Print or New.
- macOS Window menu: the active workbook's check is put back every poll,
  since choosing a check item flips it.
2026-10-22 05:12:23 +00:00
aiongg 1794c389e5 Keep the cell draft in its own workbook; Switch Windows lists open workbooks
A cell being typed when File > New ran (macOS menu, control channel) stayed
open over the new workbook, and Enter wrote it there; the first workbook
stayed clean and nothing in the UI reached it.

- Every file.* and window command (window.activate, view.newWindow,
  view.hideWindow, view.unhideWindow, view.switchWindows with a doc) first
  commits the cell and text-box drafts as Enter would, or drops a draft its
  workbook refuses, still reporting why. Finder, argv and dropped opens go
  through file.open and get the same.
- The cell editor records its workbook's uid; the grid draws a draft only
  over that workbook, and a commit always goes into it.
- view.switchWindows takes a `doc` uid to bring that workbook forward and
  lists index, doc, title, dirty, active and hidden. Menu-style calls with
  no doc open the View tab's new Switch Windows dropdown.
- macOS: the Window menu lists the open workbooks (unsaved bullet, active
  check), and Quit closes the window so unsaved workbooks are asked about.
2026-10-22 05:01:47 +00:00
prxnvt 0457f102a0 Engine: pasting only formats from a file leaves the destination's trust alone
Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.

Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.
2026-10-21 17:27:20 -04:00
prxnvt ab27d35386 Engine: a full undo history keeps one step per command that runs its own undo groups
When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.

The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.
2026-10-21 17:25:45 -04:00
prxnvt f4e05534a3 Engine: a command that runs its own undo groups makes one undo step for itself
execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.
2026-10-21 15:23:08 -04:00
prxnvt 83ae63f793 Engine: a stash item with only later chunks reads as missing and falls back to the legacy name
With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists.
2026-10-21 15:18:15 -04:00
prxnvt e165eaf3a6 Engine: a linked paste of values from a file carries the file's trust
A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.
2026-10-21 15:18:15 -04:00
prxnvt 7d1fee8c78 Engine: workbook data in chunked hidden names (cmd/stash.rs)
GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.

cmd/stash.rs stores an item `key` as hidden workbook-scope names
_gc_<key>_0, _gc_<key>_1, ..., each a formula of string literals of at
most 255 characters (UTF-16 units, as Excel counts them) joined with &,
at most 8,192 characters per name. Reading concatenates the chunks in
order and stops at a gap; it also accepts the older single _sc_<key>
name. Names are matched case-insensitively and only at workbook scope.
Keys are limited to letters, digits, _ and ., items to 4,194,304
characters, and junk around or inside the literals, duplicate or
out-of-order chunks, non-JSON and over-deep JSON all read safely.
keys(prefix) lists the stored items that start with a prefix.

Custom views, scripts, the watch list and scenarios keep their current
storage; nothing reads or writes the new names yet.

docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.
2026-10-21 14:05:48 -04:00
prxnvt b7bce3bc09 Engine: document trust for workbooks opened from files
Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.

The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
2026-10-21 14:05:48 -04:00
prxnvt d4f1e61c99 Engine: a recording buffer of top-level commands, apart from the journal
A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.

Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.

Automate › Record Actions keeps recording through the journal as before.
2026-10-21 14:05:41 -04:00
prxnvt acfd38f309 Engine: undo groups make several commands one undo step
Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.

While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.

Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
2026-10-21 14:05:41 -04:00
dotCooCoo b0ab19701d Merge main into fix/lambda-recursion-stack 2026-10-20 16:10:54 -07:00
vangel76andClaude Sonnet 5.5 f1cb4f00f4 Merge upstream/main into fix/control-auth-777
Keep the token check on the first line and the new bounded-line request parsing;
tests authenticate before sending their requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:03:55 +02:00
dotCooCoo d0fdf948db Calc: a recursive LAMBDA stops at a fixed count of 140 calls
A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of
native stack, so the depth it reached depended on frame sizes. On a
Windows release build the deepest countdown was F(F,94), where 0.7.0
reached F(F,131).

The evaluator now counts the LAMBDA calls a formula has in progress, in
call_lambda_values, which direct calls, defined names, MAP, REDUCE,
SCAN, BYROW, BYCOL and MAKEARRAY all go through. The 141st call answers
#NUM!. The countdown F(F,131), the deepest that the 400-level MAX_DEPTH
allows, has 132 calls in progress and still runs.

The stack check stays as a guard. Its budget rises to 896 KiB, which
leaves 128 KiB of a 1 MiB thread for the frames above the formula.

a_recursive_lambda_stops_at_the_same_call_on_any_stack_size runs
recursions on each side of the limit on a 1 MiB and a 64 MiB thread and
expects the same results. The 1 MiB test now runs the countdown
F(F,131).
2026-10-18 14:06:15 -07:00
dotCooCoo 3b05abe05e Merge main into fix/lambda-recursion-stack 2026-10-18 12:58:08 -07:00
dotCooCoo 09973e7831 Contributors: add dotCooCoo (Robert Lee) 2026-10-17 15:26:31 -07:00
dotCooCoo 672c102eb2 Calc: a LAMBDA that calls itself without end answers #NUM! instead of overflowing the stack
The evaluator stopped at MAX_DEPTH (400) nested eval calls. A LAMBDA
call takes one of those levels but several stack frames (eval,
eval_inner, call, builtin, apply_lambda, call_lambda_values), so on the
1 MiB main thread of the Windows build
`=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))` overflowed the stack before it
reached the limit, and the process ended.

eval now also stops once a formula has used 512 KiB of stack below its
first eval. Both limits answer #NUM!, the error Excel documents for too
many recursive calls, and a defined name that holds the recursion passes
that error on instead of answering #NAME?.

Fixes #1662
2026-10-17 15:26:31 -07:00
Gert Pellin 9d9911dca1 Merge branch 'main' into fix/1419-clear-skips-filtered-rows 2026-10-15 22:25:42 +02:00
Harrison BoothandClaude Opus 5.5 5bd6cc3d04 Options: Maximum Iterations and Maximum Change in File > Options > Formulas
The Formulas section had the calculation-mode radios and Enable iterative
calculation but not the two limits that formulas.calculationOptions already
takes. They now sit under the checkbox, disabled while iteration is off, and
commit when the field loses focus. Like Excel, out-of-range entries
(Maximum Iterations outside 1-32767, a negative or non-numeric Maximum
Change) are refused with a message instead of being clamped by the command;
the Enter that committed the field no longer also dismisses that message.

Labels and messages are translated for every interface language. A kittest
covers the disabled state, valid and invalid entries, and undo refreshing
the field. Drops the stale "no Options dialog yet" from gaps.md and
function-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:19:03 -05:00
Gert Pellin d280e6ff00 Clear: Delete and the Clear commands skip the rows a filter hides (#1419)
As in Excel, clearing a filtered selection leaves the rows the filter
hides as they are; rows hidden by hand are still cleared. Checked boxes
in hidden rows no longer hold back Delete on the boxes showing.
2026-10-15 04:44:22 +02:00
vangel76andClaude Sonnet 5.5 392fb29727 Merge upstream/main into fix/control-auth-777
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:08:11 +02:00
vangel76andClaude Sonnet 5.5 fef1f4ddb1 Security: the --control server requires an auth token and closes bad connections (#777)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:25:14 +02:00
536 changed files with 83062 additions and 45246 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: gridcraft
app-title: GridCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+2
View File
@@ -24,3 +24,5 @@ jobs:
with:
app-name: gridcraft
app-dir: GridCraft
install-relative-path: Programs/ArtCraft/GridCraft
install-scope: perUser
+7 -2
View File
@@ -23,7 +23,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `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`).
@@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
@@ -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.
+4
View File
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
+7 -2
View File
@@ -27,7 +27,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- 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.
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
@@ -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
+2221 -217
View File
File diff suppressed because it is too large Load Diff
+37 -10
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.11.1"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/gridcraft"
[workspace.dependencies]
@@ -24,6 +24,10 @@ gridcraft-engine = { path = "crates/engine" }
gridcraft-ui-egui = { path = "crates/ui-egui" }
gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
# craft-update: 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"
@@ -31,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"
@@ -43,10 +48,10 @@ sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
@@ -55,16 +60,20 @@ sys-locale = "0.3"
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
epaint_default_fonts = "0.36"
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
@@ -79,3 +88,21 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+262 -2
View File
@@ -12,5 +12,265 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
Font License 1.1.
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://github.com/source-foundry/Hack
The following text is reproduced verbatim from Hack-Regular.txt:
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
The following text is reproduced verbatim from OFL.txt:
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
is distributed under the Ubuntu Font Licence, Version 1.0.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://design.ubuntu.com/font
The following text is reproduced verbatim from UFL.txt:
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+30 -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-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
> **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
@@ -135,9 +135,23 @@ 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 |
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in |
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -147,6 +161,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
@@ -165,6 +180,8 @@ 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) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -181,6 +198,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
@@ -226,9 +244,16 @@ 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) |
| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it |
| 2026-10-22 | minor | Progress log: a native window per workbook window |
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
@@ -247,6 +272,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
@@ -254,6 +280,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | UI building blocks; progress log row |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
use super::*;
/// Functions whose result depends on the clock, a random generator or the environment.
const VOLATILE: [&str; 6] = ["RAND", "RANDARRAY", "RANDBETWEEN", "NOW", "TODAY", "INFO"];
// ---------------------------------------------------------------- calc oracle
fn functions_of(e: &Expr) -> Vec<String> {
let mut set = BTreeSet::new();
e.walk(&mut |x| {
if let Expr::Call(n, _) = x {
set.insert(n.clone());
}
});
set.into_iter().collect()
}
fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
let mut v = false;
e.walk(&mut |x| match x {
Expr::Call(n, args) => {
let filename = n == "CELL" && matches!(args.first(), Some(Expr::Text(t)) if t.eq_ignore_ascii_case("filename"));
if filename || VOLATILE.contains(&n.as_str()) {
v = true;
}
}
Expr::Name(n) if volatile_names.contains(&n.to_ascii_uppercase()) => v = true,
_ => {}
});
v
}
/// Upper-cased defined names whose formula is volatile, directly or through other names.
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
let parsed: Vec<(String, Option<Expr>)> =
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
let mut set = HashSet::new();
// Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point.
for _ in 0..=parsed.len() {
let before = set.len();
for (name, e) in &parsed {
if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) {
set.insert(name.clone());
}
}
if set.len() == before {
break;
}
}
set
}
/// The formula as the formula bar shows it.
pub(super) fn formula_text(cell: &Cell) -> String {
cell.bar_text()
}
fn cell_name(wb: &Workbook, (sheet, c): Key) -> String {
let name = wb.sheet(sheet).map(|s| s.name.as_str()).unwrap_or("?");
format!("{}!{}", quote_sheet(name), c.a1())
}
/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a
/// logical result `t="b"`; a writer that leaves the type out (`<c><f>TRUE()</f><v>1</v></c>`, as
/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number.
pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool {
matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 })
}
/// Whether GridCraft's `actual` agrees with the producer's `expected`.
pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool {
match (expected, actual) {
(Value::Number(a), Value::Number(b)) => (a - b).abs() <= tolerance * a.abs().max(b.abs()).max(1.0),
(Value::Text(a), Value::Text(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
_ => false,
}
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Empty => "blank",
Value::Number(_) => "number",
Value::Text(_) => "text",
Value::Bool(_) => "bool",
Value::Error(e) => e.as_str(),
Value::Array(_) => "array",
}
}
fn mismatch_kind(expected: &Value, actual: &Value) -> &'static str {
match (expected, actual) {
(_, Value::Empty) => "blank",
(Value::Number(_), Value::Number(_)) => "numeric",
(Value::Text(_), Value::Text(_)) => "text",
(Value::Bool(_), Value::Bool(_)) => "bool",
(Value::Error(_), _) | (_, Value::Error(_)) => "error",
_ => "type",
}
}
/// Mismatched cells indexed by (sheet, column) → rows, for precedent-area queries.
struct MismatchIndex {
cols: HashMap<(usize, u32), BTreeSet<u32>>,
/// Columns with mismatches per sheet (for very wide areas).
sheet_cols: HashMap<usize, BTreeSet<u32>>,
}
impl MismatchIndex {
fn new(keys: &[Key]) -> MismatchIndex {
let mut cols: HashMap<(usize, u32), BTreeSet<u32>> = HashMap::new();
let mut sheet_cols: HashMap<usize, BTreeSet<u32>> = HashMap::new();
for &(s, c) in keys {
cols.entry((s, c.col)).or_default().insert(c.row);
sheet_cols.entry(s).or_default().insert(c.col);
}
MismatchIndex { cols, sheet_cols }
}
/// Whether a mismatched cell other than `own` lies in the area.
fn any_in(&self, sheet: usize, r0: u32, r1: u32, c0: u32, c1: u32, own: Key) -> bool {
let Some(cols) = self.sheet_cols.get(&sheet) else { return false };
cols.range(c0..=c1)
.any(|&col| self.cols.get(&(sheet, col)).is_some_and(|rows| rows.range(r0..=r1).any(|&row| (sheet, CellRef::new(row, col)) != own)))
}
}
/// Compares every formula cell of `cached` (as the producer saved it) with `fresh` (recalculated
/// by GridCraft). Without `trust_cache` nothing is compared (see [`CalcReport::stale_cache`]).
pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: bool, opts: &Options) -> CalcReport {
let mut r = CalcReport { stale_cache: !trust_cache, ..Default::default() };
let vnames = volatile_names(cached);
let mut volatile: HashSet<Key> = HashSet::new();
for (si, sh) in cached.sheets.iter().enumerate() {
for (c, cell) in sh.cells.iter() {
if let Some(f) = &cell.formula {
r.formula_cells += 1;
if f.expr_arc().is_some_and(|e| is_volatile(&e, &vnames)) {
volatile.insert((si, c));
}
}
}
}
// Everything downstream of a volatile formula is volatile too; each key is pushed once.
let mut stack: Vec<Key> = volatile.iter().copied().collect();
let mut deps = Vec::new();
while let Some((s, c)) = stack.pop() {
deps.clear();
calc.graph.dependents(s, c, &mut deps);
if let Some(range) = fresh.sheet(s).and_then(|sh| sh.spill_ranges.get(&c)) {
calc.graph.dependents_of_range(s, *range, &mut deps);
}
for k in deps.drain(..) {
if volatile.insert(k) {
stack.push(k);
}
}
}
let mut bad: Vec<(Key, Vec<String>)> = Vec::new();
for (si, sh) in cached.sheets.iter().enumerate() {
for (c, cell) in sh.cells.iter() {
let Some(f) = &cell.formula else { continue };
if volatile.contains(&(si, c)) {
r.volatile += 1;
continue;
}
if cell.value.is_empty() || !trust_cache {
r.no_oracle += 1;
continue;
}
r.checked += 1;
let actual = fresh.sheet(si).and_then(|s| s.cell(c)).map(|x| x.value.scalar()).unwrap_or_default();
let funcs = match f.expr_arc() {
Some(e) => {
let names = functions_of(&e);
if names.is_empty() { vec!["(no function)".to_string()] } else { names }
}
None => vec!["(unparsed)".to_string()],
};
let bool_as_number = bool_cached_as_number(&cell.value, &actual);
let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance);
if bool_as_number {
r.bool_as_number += 1;
}
for n in &funcs {
let st = r.functions.entry(n.clone()).or_default();
st.checked += 1;
if !ok {
st.mismatched += 1;
}
}
if ok {
r.matched += 1;
} else {
r.mismatched += 1;
*r.signatures.entry(format!("{} → {}", type_name(&cell.value), type_name(&actual))).or_default() += 1;
bad.push(((si, c), funcs));
}
}
}
// A mismatch is primary when none of its precedent cells mismatch.
let keys: Vec<Key> = bad.iter().map(|(k, _)| *k).collect();
let index = MismatchIndex::new(&keys);
let mut listed: Vec<(bool, Key, Vec<String>)> = Vec::with_capacity(bad.len());
for (k, funcs) in bad {
let upstream = calc
.graph
.precedents_of(k)
.iter()
.any(|a| index.any_in(a.sheet, a.range.start.row, a.range.end.row, a.range.start.col, a.range.end.col, k));
if !upstream {
for n in &funcs {
r.functions.entry(n.clone()).or_default().primary += 1;
}
}
listed.push((!upstream, k, funcs));
}
// Primary mismatches first, each group in sheet then row-major order (already sorted).
listed.sort_by_key(|(primary, _, _)| !*primary);
for (primary, k, functions) in listed.into_iter().take(opts.max_report) {
let cell = cached.sheet(k.0).and_then(|s| s.cell(k.1));
let expected = cell.map(|c| c.value.clone()).unwrap_or_default();
let actual = fresh.sheet(k.0).and_then(|s| s.cell(k.1)).map(|x| x.value.scalar()).unwrap_or_default();
r.mismatches.push(Mismatch {
cell: cell_name(cached, k),
formula: cell.map(formula_text).unwrap_or_default(),
kind: mismatch_kind(&expected, &actual),
expected,
actual,
functions,
primary,
});
}
r
}
+185
View File
@@ -0,0 +1,185 @@
use super::calc::formula_text;
use super::package::{REBUILT_KINDS, relationship_kinds, validate_package};
use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet};
// ---------------------------------------------------------------- round trip
/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against
/// `source` (the file `wb` was read from), reads it back and compares.
pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip {
let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") {
Ok(b) => b,
Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] },
};
let mut package = Diffs::new("package", opts.max_report);
match validate_package(&bytes) {
Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)),
Err(e) => package.push(|| e),
}
if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) {
for (key, kind) in &before {
if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) {
package.push(|| format!("lost relationship {kind}"));
}
}
}
let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) {
Ok((b, _)) => b,
Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] },
};
let mut categories = diff_workbooks(wb, &back, opts);
categories.push(package.done());
RoundTrip { error: None, categories }
}
pub(super) fn value_show(v: &Value) -> String {
match v {
Value::Empty => "(blank)".to_string(),
Value::Text(t) => serde_json::to_string(t.as_ref()).unwrap_or_default(),
Value::Number(n) => number_text(*n),
Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
Value::Error(e) => e.as_str().to_string(),
Value::Array(_) => v.scalar().display(),
}
}
fn opt_formula(c: Option<&Cell>) -> String {
c.filter(|c| c.formula.is_some()).map(formula_text).unwrap_or_default()
}
/// Per-sheet comparison of two lists, reporting what one side has and the other lacks.
fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet<String>, b: &BTreeSet<String>) {
for x in a.difference(b) {
d.push(|| format!("{sheet}: lost {x}"));
}
for x in b.difference(a) {
d.push(|| format!("{sheet}: gained {x}"));
}
}
/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number
/// itself is left out, so renumbering without changing the order isn't a difference.
fn cond_format_list(s: &Sheet) -> Vec<String> {
let mut cfs: Vec<_> = s.cond_formats.iter().collect();
cfs.sort_by_key(|c| c.priority);
cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect()
}
fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) {
if a != b {
d.push(|| format!("{sheet}: {a} → {b}"));
}
}
/// A value as the file format can hold it: `#SPILL!`/`#CALC!` are cached as `#VALUE!` and an
/// unresolved circular reference as 0 (what Excel writes), so those aren't round-trip losses.
fn as_cached(v: Value) -> Value {
match v {
Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value),
Value::Error(CellError::Circ) => Value::Number(0.0),
v => v,
}
}
fn num_fmt<'a>(wb: &'a Workbook, c: Option<&Cell>) -> &'a str {
wb.styles.get(c.map(|c| c.style).unwrap_or_default()).num_fmt.as_str()
}
/// Feature-by-feature comparison of a workbook (`a`) with what reading it back gave (`b`). Sheets
/// are paired by position.
pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec<Category> {
let m = opts.max_report;
let mut sheets = Diffs::new("sheets", m);
let (an, bn): (Vec<&str>, Vec<&str>) = (a.sheets.iter().map(|s| s.name.as_str()).collect(), b.sheets.iter().map(|s| s.name.as_str()).collect());
if an != bn {
sheets.push(|| format!("{an:?} → {bn:?}"));
}
let mut values = Diffs::new("values", m);
let mut formulas = Diffs::new("formulas", m);
let mut num_fmts = Diffs::new("numFmts", m);
let mut merges = Diffs::new("merges", m);
let mut tables = Diffs::new("tables", m);
let mut cond_formats = Diffs::new("condFormats", m);
let mut validations = Diffs::new("validations", m);
let mut charts = Diffs::new("charts", m);
let mut comments = Diffs::new("comments", m);
let mut hyperlinks = Diffs::new("hyperlinks", m);
let mut pivots = Diffs::new("pivots", m);
for (sa, sb) in a.sheets.iter().zip(b.sheets.iter()) {
let name = quote_sheet(&sa.name);
let mut cell = |c: CellRef, ca: Option<&Cell>, cb: Option<&Cell>| {
let cached = |x: Option<&Cell>| as_cached(x.map(|x| x.value.scalar()).unwrap_or_default());
let (va, vb) = (cached(ca), cached(cb));
if va != vb {
values.push(|| format!("{name}!{}: {} → {}", c.a1(), value_show(&va), value_show(&vb)));
}
let (fa, fb) = (opt_formula(ca), opt_formula(cb));
if fa != fb {
formulas.push(|| format!("{name}!{}: {fa} → {fb}", c.a1()));
}
let (na, nb) = (num_fmt(a, ca), num_fmt(b, cb));
if na != nb {
num_fmts.push(|| format!("{name}!{}: {na} → {nb}", c.a1()));
}
};
for (c, ca) in sa.cells.iter() {
cell(c, Some(ca), sb.cell(c));
}
for (c, cb) in sb.cells.iter() {
if sa.cell(c).is_none() {
cell(c, None, Some(cb));
}
}
let set = |f: &dyn Fn(&Sheet) -> Vec<String>, s: &Sheet| f(s).into_iter().collect::<BTreeSet<String>>();
let merge_list = |s: &Sheet| -> Vec<String> { s.merges.iter().map(|r| r.a1()).collect() };
diff_sets(&mut merges, &name, &set(&merge_list, sa), &set(&merge_list, sb));
let table_list = |s: &Sheet| -> Vec<String> {
s.tables
.iter()
.map(|t| format!("{} {} [{}]", t.name, t.range.a1(), t.columns.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(", ")))
.collect()
};
diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb));
let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb));
if cfa.len() != cfb.len() {
cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len()));
} else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) {
cond_formats.push(|| format!("{name}: {x} → {y}"));
}
diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len());
let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::<Vec<_>>();
let (ca, cb) = (chart_list(sa), chart_list(sb));
if ca != cb {
charts.push(|| format!("{name}: {ca:?} → {cb:?}"));
}
diff_count(&mut comments, &name, sa.comments.len(), sb.comments.len());
diff_count(&mut hyperlinks, &name, sa.hyperlinks.len(), sb.hyperlinks.len());
diff_count(&mut pivots, &name, sa.pivots.len(), sb.pivots.len());
}
let mut names = Diffs::new("names", m);
let name_list = |wb: &Workbook| -> BTreeSet<String> {
wb.names
.iter()
.map(|n| {
let scope = n.scope.map(|i| format!("{}!", quote_sheet(wb.sheet(i).map(|s| s.name.as_str()).unwrap_or("?")))).unwrap_or_default();
format!("{scope}{}={}", n.name, n.formula)
})
.collect()
};
diff_sets(&mut names, "workbook", &name_list(a), &name_list(b));
vec![
sheets.done(),
values.done(),
formulas.done(),
num_fmts.done(),
merges.done(),
names.done(),
tables.done(),
cond_formats.done(),
validations.done(),
charts.done(),
comments.done(),
hyperlinks.done(),
pivots.done(),
]
}
+331
View File
@@ -0,0 +1,331 @@
use super::*;
/// Largest package part read while validating a written package.
const MAX_XML_PART: u64 = 64 * 1024 * 1024;
/// Most zip entries looked at while validating a written package.
const MAX_ENTRIES: usize = 200_000;
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
/// a cache Excel rebuilds, and GridCraft never writes it.
pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];
// ---------------------------------------------------------------- package validation
fn local_name(name: &[u8]) -> &[u8] {
match name.iter().rposition(|&b| b == b':') {
Some(i) => name.get(i + 1..).unwrap_or(name),
None => name,
}
}
/// `(element local name, attributes)` for every start/empty element of an XML part.
type XmlElement = (String, Vec<(String, String)>);
fn xml_elements(xml: &[u8]) -> Result<Vec<XmlElement>, String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_reader(xml);
let mut buf = Vec::new();
let mut out = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let attrs = e
.attributes()
.with_checks(false)
.flatten()
.map(|a| {
let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) {
Ok(v) => v.into_owned(),
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
};
(String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v)
})
.collect();
out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs));
}
Ok(Event::Eof) => break,
Ok(_) => {}
Err(e) => return Err(format!("invalid XML: {e}")),
}
buf.clear();
}
Ok(out)
}
fn attr<'a>(attrs: &'a [(String, String)], k: &str) -> Option<&'a str> {
attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
}
/// Lower-cased part name without a leading slash: OPC part names compare case-insensitively.
fn part_key(name: &str) -> String {
name.trim_start_matches('/').to_ascii_lowercase()
}
/// `%XX` escapes in a relationship target.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = |j: usize| b.get(j).and_then(|h| (*h as char).to_digit(16));
match (c, hex(i + 1), hex(i + 2)) {
(b'%', Some(h), Some(l)) => {
out.push((h * 16 + l) as u8);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// Resolves a relationship target against the source part's directory.
fn resolve_target(source_dir: &str, target: &str) -> String {
let target = percent_decode(target.split('#').next().unwrap_or(target));
let joined = match target.strip_prefix('/') {
Some(abs) => abs.to_string(),
None if source_dir.is_empty() => target,
None => format!("{source_dir}/{target}"),
};
let mut parts: Vec<&str> = Vec::new();
for seg in joined.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
type Zip<'a> = zip::ZipArchive<Cursor<&'a [u8]>>;
fn zip_names(zip: &mut Zip<'_>) -> Vec<String> {
(0..zip.len().min(MAX_ENTRIES)).filter_map(|i| zip.by_index_raw(i).ok().filter(|f| !f.is_dir()).map(|f| f.name().to_string())).collect()
}
fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result<Vec<u8>, String> {
let f = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?;
let mut buf = Vec::new();
f.take(MAX_XML_PART).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?;
Ok(buf)
}
/// Whether the producer flagged its cached results as stale with `<calcPr fullCalcOnLoad="1">`:
/// libraries that don't calculate write it, and store 0 or nothing as every formula's value.
pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool {
let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false };
let names = zip_names(&mut zip);
let find = |key: &str| names.iter().find(|n| part_key(n) == key).cloned();
let mut workbook = "xl/workbook.xml".to_string();
if let Some(rels) = find("_rels/.rels")
&& let Ok(xml) = read_zip_part(&mut zip, &rels)
&& let Ok(els) = xml_elements(&xml)
&& let Some(target) = els
.iter()
.find(|(e, a)| e == "Relationship" && attr(a, "Type").is_some_and(|t| t.ends_with("/officeDocument")))
.and_then(|(_, a)| attr(a, "Target"))
{
workbook = resolve_target("", target);
}
let Some(name) = find(&part_key(&workbook)) else { return false };
let Ok(xml) = read_zip_part(&mut zip, &name) else { return false };
xml_elements(&xml)
.ok()
.into_iter()
.flatten()
.any(|(e, a)| e == "calcPr" && attr(&a, "fullCalcOnLoad").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")))
}
/// Problems Excel would repair in a written package: parts without a content type, content-type
/// overrides for missing parts, relationship files for missing parts, duplicate relationship ids
/// and internal relationships pointing at missing parts. `Err` when the package can't be read.
pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
let names = zip_names(&mut zip);
let parts: HashSet<String> = names.iter().map(|n| part_key(n)).collect();
let mut read_part = |name: &str| read_zip_part(&mut zip, name);
let mut issues = Vec::new();
let ct_name = names.iter().find(|n| part_key(n) == "[content_types].xml").cloned();
let (mut defaults, mut overrides) = (HashSet::new(), HashSet::new());
match ct_name {
None => issues.push("no [Content_Types].xml".to_string()),
Some(n) => {
for (el, attrs) in xml_elements(&read_part(&n)?).map_err(|e| format!("[Content_Types].xml: {e}"))? {
match el.as_str() {
"Default" => {
if let Some(x) = attr(&attrs, "Extension") {
defaults.insert(x.to_ascii_lowercase());
}
}
"Override" => {
if let Some(p) = attr(&attrs, "PartName") {
overrides.insert(part_key(p));
}
}
_ => {}
}
}
}
}
let mut sorted: Vec<&String> = names.iter().collect();
sorted.sort();
for n in &sorted {
let key = part_key(n);
if key == "[content_types].xml" {
continue;
}
let ext = key.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_string());
if !overrides.contains(&key) && !ext.is_some_and(|e| defaults.contains(&e)) {
issues.push(format!("part {n} has no content type"));
}
}
let mut missing_overrides: Vec<&String> = overrides.iter().filter(|p| !parts.contains(*p)).collect();
missing_overrides.sort();
for p in missing_overrides {
issues.push(format!("content type override for missing part /{p}"));
}
for n in sorted {
let key = part_key(n);
let Some(rels_file) = key.strip_suffix(".rels") else { continue };
// `dir/_rels/name.rels` describes `dir/name`; `_rels/.rels` the package itself.
let (dir, file) = match rels_file.rsplit_once('/') {
Some((d, f)) => (d, f),
None => continue,
};
let Some(src_dir) = dir.strip_suffix("_rels").map(|d| d.trim_end_matches('/')) else { continue };
let source = if src_dir.is_empty() { file.to_string() } else { format!("{src_dir}/{file}") };
if !source.is_empty() && !parts.contains(&source) {
issues.push(format!("{n} describes missing part {source}"));
}
let mut ids = HashSet::new();
for (el, attrs) in xml_elements(&read_part(n)?).map_err(|e| format!("{n}: {e}"))? {
if el != "Relationship" {
continue;
}
let id = attr(&attrs, "Id").unwrap_or("");
if !ids.insert(id.to_string()) {
issues.push(format!("{n}: duplicate relationship id {id}"));
}
if attr(&attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) {
continue;
}
let target = attr(&attrs, "Target").unwrap_or("");
let resolved = resolve_target(src_dir, target);
if !parts.contains(&resolved.to_ascii_lowercase()) {
let kind = attr(&attrs, "Type").and_then(|t| t.rsplit('/').next()).unwrap_or("?");
issues.push(format!("{n}: {id} ({kind}) targets missing part {resolved}"));
}
}
}
Ok(issues)
}
/// Every relationship kind (last segment of the type, the same in the transitional and strict
/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
pub(super) fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
let names = zip_names(&mut zip);
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
// Several relationships name the same part; each fresh check decompressed it again, so
// repeated references to one highly compressible part multiplied the work. The verdict is
// remembered per (part, named content) and the decompression the package can cause is
// budgeted as a whole (finding: repeated decompression).
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
let mut budget: u64 = 512 * 1024 * 1024;
let mut kinds = BTreeMap::new();
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
let target = attr(attrs, "Target").unwrap_or("");
if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') {
continue;
}
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
if !external
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
{
continue;
}
kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string());
}
}
Ok(kinds)
}
/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the
/// empty string for the package's `_rels/.rels`.
fn rels_source_dir(key: &str) -> Option<String> {
let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?;
Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string())
}
/// Whether a part has content: an element under its root, or for comments (whose authors list
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
/// package's remaining checks may cause, and once it is spent the part counts as having content.
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
"comments" => Some(b"comment"),
"vmldrawing" => Some(b"shape"),
_ => None,
};
let marker = match wanted {
Some(b"comment") => "comment",
Some(_) => "shape",
None => "any",
};
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
return *v;
}
let v = holds_something_uncached(zip, name, marker, budget);
verdicts.insert((name.to_ascii_lowercase(), marker), v);
v
}
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
use quick_xml::events::Event;
let Ok(f) = zip.by_name(name) else { return true };
if *budget == 0 {
return true;
}
let take = f.size().min(MAX_XML_PART).min(*budget);
*budget -= take;
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
let wanted: Option<&'static [u8]> = match marker {
"comment" => Some(b"comment"),
"shape" => Some(b"shape"),
_ => None,
};
let mut buf = Vec::new();
let mut in_root = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let found = match wanted {
Some(w) => local_name(e.name().as_ref()) == w,
None => in_root,
};
if found {
return true;
}
in_root = true;
}
Ok(Event::Eof) => return false,
Ok(_) => {}
Err(_) => return true,
}
buf.clear();
}
}
+280
View File
@@ -0,0 +1,280 @@
use super::fidelity::value_show;
use super::*;
// ---------------------------------------------------------------- output
#[derive(Default)]
struct Totals {
files: usize,
opened: usize,
failed: usize,
formula_cells: usize,
checked: usize,
matched: usize,
mismatched: usize,
no_oracle: usize,
volatile: usize,
bool_as_number: usize,
round_trip_failed: usize,
round_trip_differing: usize,
categories: BTreeMap<&'static str, usize>,
functions: BTreeMap<String, FnStat>,
signatures: BTreeMap<String, usize>,
}
fn totals(reports: &[FileReport]) -> Totals {
let mut t = Totals { files: reports.len(), ..Default::default() };
for r in reports {
if r.error.is_some() {
t.failed += 1;
continue;
}
t.opened += 1;
t.formula_cells += r.calc.formula_cells;
t.checked += r.calc.checked;
t.matched += r.calc.matched;
t.mismatched += r.calc.mismatched;
t.no_oracle += r.calc.no_oracle;
t.volatile += r.calc.volatile;
t.bool_as_number += r.calc.bool_as_number;
if r.round_trip.error.is_some() {
t.round_trip_failed += 1;
}
if !r.round_trip.is_clean() {
t.round_trip_differing += 1;
}
for c in r.round_trip.differing() {
*t.categories.entry(c).or_default() += 1;
}
for (n, s) in &r.calc.functions {
t.functions.entry(n.clone()).or_default().add(s);
}
for (sig, n) in &r.calc.signatures {
*t.signatures.entry(sig.clone()).or_default() += n;
}
}
t
}
/// Functions with mismatches, worst first: most primary mismatches, then most mismatches.
fn ranked(functions: &BTreeMap<String, FnStat>) -> Vec<(&String, &FnStat)> {
let mut v: Vec<(&String, &FnStat)> = functions.iter().filter(|(_, s)| s.mismatched > 0).collect();
v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0)));
v
}
fn functions_json(functions: &BTreeMap<String, FnStat>) -> Json {
let mut v: Vec<(&String, &FnStat)> = functions.iter().collect();
v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0)));
Json::Array(v.into_iter().map(|(n, s)| json!({"name": n, "checked": s.checked, "mismatched": s.mismatched, "primary": s.primary})).collect())
}
pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
let files: Vec<Json> = reports
.iter()
.map(|r| {
let c = &r.calc;
let mismatches: Vec<Json> = c
.mismatches
.iter()
.map(|m| {
json!({
"cell": m.cell, "formula": m.formula, "expected": value_json(&m.expected), "actual": value_json(&m.actual),
"kind": m.kind, "functions": m.functions, "primary": m.primary,
})
})
.collect();
let categories: serde_json::Map<String, Json> = r
.round_trip
.categories
.iter()
.map(|cat| (cat.name.to_string(), json!({"equal": cat.diffs == 0, "diffs": cat.diffs, "examples": cat.examples})))
.collect();
json!({
"path": r.path,
"opened": r.error.is_none(),
"error": r.error,
"warnings": r.warnings,
"calc": {
"formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched,
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number,
"mismatches": mismatches, "functions": functions_json(&c.functions),
"signatures": c.signatures,
},
"roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories},
})
})
.collect();
let t = totals(reports);
json!({
"schema": SCHEMA,
"tolerance": opts.tolerance,
"files": files,
"totals": {
"files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked,
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number,
"roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories,
},
"functions": functions_json(&t.functions),
"signatures": t.signatures,
})
}
/// One report line: line breaks and tabs shown escaped so that they stay visible as differences.
fn one_line(s: &str) -> String {
s.replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t")
}
/// `…tail` of a long label.
fn fit(s: &str, w: usize) -> String {
let n = s.chars().count();
if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::<String>()) }
}
fn print_file_reports(reports: &[FileReport]) {
for r in reports {
outln!("== {}", r.path);
if let Some(e) = &r.error {
outln!(" can't open: {e}");
continue;
}
let c = &r.calc;
outln!(
" formulas {}: {} checked, {} matched, {} mismatched, {} no oracle, {} volatile",
c.formula_cells,
c.checked,
c.matched,
c.mismatched,
c.no_oracle,
c.volatile
);
if c.stale_cache {
outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)");
}
if c.bool_as_number > 0 {
outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number);
}
for m in &c.mismatches {
let tag = if m.primary { "mismatch" } else { " (downstream)" };
outln!(
" {tag} {} {} expected {} got {} [{}]",
m.cell,
one_line(&m.formula),
value_show(&m.expected),
value_show(&m.actual),
m.functions.join(" ")
);
}
if c.mismatched > c.mismatches.len() {
outln!(" … {} more mismatches", c.mismatched - c.mismatches.len());
}
let rt = &r.round_trip;
if let Some(e) = &rt.error {
outln!(" round trip: {e}");
}
let differing: Vec<&Category> = rt.categories.iter().filter(|c| c.diffs > 0).collect();
if differing.is_empty() && rt.error.is_none() {
outln!(" round trip: all equal");
}
for cat in differing {
outln!(" round trip {}: {} difference(s)", cat.name, cat.diffs);
for e in &cat.examples {
outln!(" {}", one_line(e));
}
if cat.diffs > cat.examples.len() {
outln!(" … {} more", cat.diffs - cat.examples.len());
}
}
}
}
fn print_file_table(reports: &[FileReport]) {
let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50);
outln!(
"{:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip",
"file",
"formulas",
"checked",
"matched",
"mismatch",
"no-oracle",
"volatile"
);
for r in reports {
let label = fit(&r.path, w);
if let Some(e) = &r.error {
outln!("{label:<w$} can't open: {}", one_line(&fit(e, 100)));
continue;
}
let c = &r.calc;
let rt = match (&r.round_trip.error, r.round_trip.differing()) {
(Some(_), _) => "FAILED".to_string(),
(None, d) if d.is_empty() => "ok".to_string(),
(None, d) => d.join(","),
};
outln!(
"{label:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} {rt}",
c.formula_cells,
c.checked,
c.matched,
c.mismatched,
c.no_oracle,
c.volatile
);
}
}
fn print_rankings(t: &Totals, opts: &Options) {
let top = ranked(&t.functions);
if !top.is_empty() {
outln!("");
outln!("top mismatching functions (primary = no mismatching precedent):");
outln!(" {:<20} {:>8} {:>10} {:>8}", "function", "primary", "mismatched", "checked");
for (n, s) in top.iter().take(opts.max_report.max(1)) {
outln!(" {:<20} {:>8} {:>10} {:>8}", n, s.primary, s.mismatched, s.checked);
}
}
let mut sigs: Vec<(&String, &usize)> = t.signatures.iter().collect();
sigs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
if !sigs.is_empty() {
outln!("");
outln!("mismatch signatures (expected → GridCraft):");
for (sig, n) in sigs.iter().take(opts.max_report.max(1)) {
outln!(" {n:>8} {sig}");
}
}
}
fn print_totals(t: &Totals) {
outln!("");
outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed);
outln!(
"formula cells: {} ({} checked: {} matched, {} mismatched; {} no oracle; {} volatile)",
t.formula_cells,
t.checked,
t.matched,
t.mismatched,
t.no_oracle,
t.volatile
);
if t.bool_as_number > 0 {
outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number);
}
let cats: Vec<String> = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect();
outln!(
"round trip: {} of {} files differ{}{}",
t.round_trip_differing,
t.opened,
if t.round_trip_failed > 0 { format!(" ({} failed)", t.round_trip_failed) } else { String::new() },
if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) }
);
}
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
print_file_reports(reports);
outln!("");
print_file_table(reports);
let t = totals(reports);
print_rankings(&t, opts);
print_totals(&t);
}
+450
View File
@@ -0,0 +1,450 @@
#![allow(clippy::unwrap_used)]
use std::io::Write as _;
use std::sync::Arc;
use gridcraft_engine::model::Formula;
use super::*;
#[test]
fn discovery_reports_a_directory_read_error() {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
let mut seen = std::collections::HashSet::new();
assert!(walk(&path, 0, &mut discovery, &mut seen));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
let _ = std::fs::remove_file(path);
}
#[test]
fn discovery_reports_omissions_only_after_the_file_limit() {
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
assert!(discovery.failures.is_empty());
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
assert_eq!(discovery.files.len(), MAX_FILES);
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
}
fn at(a1: &str) -> CellRef {
CellRef::parse(a1).unwrap()
}
/// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a
/// formula with that cached value, anything else a constant.
fn book(cells: &[(&str, &str, Value)]) -> Workbook {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
for (a1, input, cached) in cells {
let cell = if input.starts_with('=') {
Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() }
} else {
Cell::value(cached.clone())
};
sh.set_cell(at(a1), cell);
}
wb
}
fn check(wb: &Workbook, opts: &Options) -> FileReport {
let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap();
check_bytes("t.xlsx", &bytes, opts)
}
fn n(x: f64) -> Value {
Value::number(x)
}
#[test]
fn matching_cached_values_pass() {
let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]);
let r = check(&wb, &Options::default());
assert_eq!(r.error, None);
assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0));
assert!(r.is_clean(), "{r:?}");
}
#[test]
fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() {
// Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with
// Excel's 7, so it mismatches too, but only as a downstream effect.
let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2));
let first = &r.calc.mismatches[0];
assert_eq!(first.cell, "Sheet1!B1");
assert_eq!(first.formula, "=SUM(A1:A2)");
assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true));
assert!(!r.calc.mismatches[1].primary);
assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 });
assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 });
assert!(!r.is_clean());
}
#[test]
fn errors_compare_by_kind() {
let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2));
assert!(r.calc.mismatches.iter().all(|m| m.kind == "error"));
assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1));
assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1));
assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0));
assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0));
}
#[test]
fn numbers_compare_within_tolerance() {
assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9));
assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12));
// Absolute near zero.
assert!(values_match(&n(1e-12), &n(0.0), 1e-9));
assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9));
assert!(!values_match(&n(0.0), &Value::Empty, 1.0));
assert!(!values_match(&Value::text("1"), &n(1.0), 1.0));
assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0));
assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0));
assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0));
let wb = book(&[("A1", "=1+2", n(3.0000000001))]);
assert_eq!(check(&wb, &Options::default()).calc.matched, 1);
assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
}
/// Business Central writes TRUE and FALSE as `<c><f>TRUE()</f><v>1</v></c>`, without `t="b"`:
/// the cache holds the right value under a number type, so it matches and is counted apart.
#[test]
fn booleans_cached_as_numbers_match_and_are_counted_apart() {
let wb = book(&[
("A1", "=TRUE()", n(1.0)),
("B1", "=FALSE()", n(0.0)),
("C1", "=TRUE()", Value::Bool(true)),
("D1", "=1=1", n(0.0)),
("E1", "=TRUE()+0", n(1.0)),
]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1));
// A cached 0 for TRUE is still a mismatch, and a number result is compared as a number.
assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1");
assert_eq!(r.calc.signatures.get("number → bool"), Some(&1));
assert_eq!(r.calc.bool_as_number, 2);
let v = report_json(std::slice::from_ref(&r), &Options::default());
assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2)));
assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true)));
assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false)));
assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true)));
assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0)));
}
#[test]
fn missing_cached_values_and_volatile_formulas_are_skipped() {
let wb =
book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]);
let r = check(&wb, &Options::default());
assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3));
}
#[test]
fn full_calc_on_load_means_no_oracle() {
// A formula without a cached value makes GridCraft's writer flag the file
// `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached"
// value must not count as a mismatch then.
let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]);
let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
assert!(full_calc_on_load(&bytes));
let r = check_bytes("t.xlsx", &bytes, &Options::default());
assert!(r.calc.stale_cache);
assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0));
let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap();
assert!(!full_calc_on_load(&fresh));
assert!(!full_calc_on_load(b"not a zip"));
}
#[test]
fn volatile_names_propagate() {
let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]);
wb.names.push(gridcraft_engine::model::DefinedName {
name: "Stamp".into(),
scope: None,
formula: "TODAY()".into(),
comment: String::new(),
hidden: false,
});
assert!(volatile_names(&wb).contains("STAMP"));
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
}
#[test]
fn round_trip_of_a_simple_book_is_clean() {
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
let rt = round_trip(&wb, &source, &Options::default());
assert_eq!(rt.error, None);
assert!(rt.is_clean(), "{rt:?}");
assert!(rt.categories.iter().any(|c| c.name == "package"));
}
#[test]
fn diff_detects_lost_features() {
let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]);
a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
let mut b = a.clone();
let sh = b.sheet_mut(0).unwrap();
sh.merges.clear();
sh.set_cell(at("A1"), Cell::value(n(3.0)));
sh.set_cell(at("B1"), Cell::value(n(6.0)));
b.sheets.push(Arc::new(Sheet::new("Extra")));
let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() });
let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap();
assert_eq!(get("values").diffs, 1);
assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]);
assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]);
assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]);
assert_eq!(get("sheets").diffs, 1);
assert_eq!(get("tables").diffs, 0);
}
#[test]
fn diff_detects_changed_cond_format_rule() {
use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
let bar = |gradient: bool, priority: u32| CondFormat {
ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
priority,
stop_if_true: false,
};
let with = |cf: CondFormat| {
let mut wb = book(&[("A1", "2", n(2.0))]);
wb.sheet_mut(0).unwrap().cond_formats.push(cf);
wb
};
let diffs = |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
}
fn zip_of(parts: &[(&str, &str)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in parts {
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(body.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
#[test]
fn package_validation_finds_what_excel_would_repair() {
let bytes = zip_of(&[
(
"[Content_Types].xml",
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="r"/><Override PartName="/xl/workbook.xml" ContentType="w"/><Override PartName="/xl/gone.xml" ContentType="g"/></Types>"#,
),
("_rels/.rels", r#"<Relationships><Relationship Id="rId1" Type="x/officeDocument" Target="xl/workbook.xml"/></Relationships>"#),
("xl/workbook.xml", "<workbook/>"),
("xl/styles.xml", "<styleSheet/>"),
(
"xl/_rels/workbook.xml.rels",
r#"<Relationships><Relationship Id="rId1" Type="x/styles" Target="styles.xml"/><Relationship Id="rId2" Type="x/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="x/hyperlink" Target="https://example.com" TargetMode="External"/><Relationship Id="rId3" Type="x/theme" Target="/xl/../xl/styles.xml"/></Relationships>"#,
),
]);
let issues = validate_package(&bytes).unwrap();
assert_eq!(
issues,
[
"part xl/styles.xml has no content type",
"content type override for missing part /xl/gone.xml",
"xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml",
"xl/_rels/workbook.xml.rels: duplicate relationship id rId2",
]
);
assert!(validate_package(b"not a zip").is_err());
let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap();
assert_eq!(validate_package(&own).unwrap(), Vec::<String>::new());
}
const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
const MADE_UP: &str = r#"<Relationship Id="rId90" Type="http://example.com/relationships/madeUp" Target="madeUp.xml"/>"#;
/// Not empty: an empty part isn't a loss.
const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", "<madeUp><item/></madeUp>");
/// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut pending: Vec<(&str, &str)> = rels.to_vec();
for i in 0..src.len() {
let mut f = src.by_index(i).unwrap();
let name = f.name().to_string();
let mut body = String::new();
f.read_to_string(&mut body).unwrap();
if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
body = body.replace("</Relationships>", &format!("{}</Relationships>", pending.remove(at).1));
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(body.as_bytes()).unwrap();
}
let created = pending.iter().map(|(r, extra)| {
(*r, format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{extra}</Relationships>"#))
});
for (name, body) in created.collect::<Vec<_>>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(body.as_bytes()).unwrap();
}
out.finish().unwrap().into_inner()
}
/// The `package` examples of checking `source`.
fn package_of(source: &[u8]) -> Vec<String> {
let r = check_bytes("t.xlsx", source, &Options::default());
r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
}
fn plain() -> Vec<u8> {
gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
}
#[test]
fn package_reports_relationship_kinds_the_output_lost() {
// A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
// (Excel rebuilds it), so its absence isn't a loss.
let calc_chain =
r#"<Relationship Id="rId91" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" Target="calcChain.xml"/>"#;
let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "<calcChain/>")]);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
}
// In the three tests below the made-up kind stays lost, so the comparison is known to run.
#[test]
fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
// openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
let link = r##"<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="#Sheet1!B2" TargetMode="External"/>"##;
let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn comments_without_a_comment_are_not_a_loss() {
let rels = r#"<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>"#;
let comments = r#"<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList/></comments>"#;
let vml = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><o:shapelayout><o:idmap data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" path="m,l,21600r21600,l21600,xe"><v:path gradientshapeok="t"/></v:shapetype></xml>"#;
let source = with_rels(
&plain(),
&[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
&[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn empty_drawings_and_person_lists_are_not_a_loss() {
let drawing = r#"<Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>"#;
let person = r#"<Relationship Id="rId92" Type="http://schemas.microsoft.com/office/2017/10/relationships/person" Target="persons/person.xml"/>"#;
let source = with_rels(
&plain(),
&[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
&[
MADE_UP_PART,
("xl/drawings/drawing1.xml", r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"/>"#),
("xl/persons/person.xml", r#"<personList xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"/>"#),
],
);
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn json_lists_warning_messages() {
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
let v = report_json(&[with, without], &Options::default());
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
assert_eq!(v["files"][1]["warnings"], json!([]));
}
#[test]
fn unopenable_files_are_reported() {
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
assert!(r.error.is_some());
assert!(!r.is_clean());
}
#[test]
fn json_schema_keys() {
let wb = book(&[("A1", "=1+1", n(3.0))]);
let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())];
let v = report_json(&reports, &Options::default());
let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::<BTreeSet<String>>();
let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::<BTreeSet<String>>();
assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"]));
assert_eq!(v["schema"], SCHEMA);
let f = &v["files"][0];
assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
assert_eq!(
keys(&f["calc"]),
set(&[
"formulaCells",
"checked",
"matched",
"mismatched",
"noOracle",
"staleCache",
"volatile",
"boolAsNumber",
"mismatches",
"functions",
"signatures"
])
);
assert_eq!(f["calc"]["signatures"], json!({"number → number": 1}));
assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"]));
assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0));
assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"]));
assert_eq!(
keys(&f["roundTrip"]["categories"]),
set(&[
"sheets",
"values",
"formulas",
"numFmts",
"merges",
"names",
"tables",
"condFormats",
"validations",
"charts",
"comments",
"hyperlinks",
"pivots",
"package"
])
);
assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"]));
assert_eq!(v["files"][1]["opened"], json!(false));
assert_eq!(
keys(&v["totals"]),
set(&[
"files",
"opened",
"failedToOpen",
"formulaCells",
"checked",
"matched",
"mismatched",
"noOracle",
"volatile",
"boolAsNumber",
"roundTripFailed",
"roundTripDiffering",
"categoriesDiffering"
])
);
assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"]));
}
+17 -1
View File
@@ -195,7 +195,18 @@ fn pretty(v: &Value) -> String {
/// A number as a person would type it: `6`, `0.5`, `1e+300`.
fn number_text(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") }
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
} else if n.is_finite() && (n.abs() >= 1e15 || n.abs() < 1e-5) {
// Rust's `Display` never uses exponent form; `{:e}` omits the `+` on positive exponents.
let s = format!("{n:e}");
match s.split_once('e') {
Some((m, e)) if !e.starts_with('-') => format!("{m}e+{e}"),
_ => s,
}
} else {
format!("{n}")
}
}
/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text.
@@ -606,6 +617,11 @@ mod tests {
fn value_texts() {
assert_eq!(value_text(&json!(6.0)), "6");
assert_eq!(value_text(&json!(0.25)), "0.25");
assert_eq!(value_text(&json!(1e300)), "1e+300");
assert_eq!(value_text(&json!(-1.5e300)), "-1.5e+300");
assert_eq!(value_text(&json!(1e-20)), "1e-20");
assert_eq!(value_text(&json!(1e-300)), "1e-300");
assert_eq!(value_text(&json!(0.0001)), "0.0001");
assert_eq!(value_text(&json!(true)), "TRUE");
assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!");
assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx");
-2
View File
@@ -1,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
@@ -11,8 +11,9 @@
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
//! `localStorage` (`prefs`).
//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
+24 -6
View File
@@ -8,6 +8,7 @@ use wasm_bindgen::JsValue;
const CANVAS_ID: &str = "gridcraft_canvas";
const LOADING_ID: &str = "gridcraft_loading";
/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -60,8 +61,21 @@ pub fn start() {
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
// `?chrome=none|notitlebar|nofiletab`: a host that embeds the sheet in its own window and owns the files
// can leave the title bar and/or the File tab undrawn (`Chrome`, #1877). An unknown value keeps
// everything, as the default does.
if let Some(chrome) = query_param("chrome") {
match chrome.as_str() {
"none" => app.chrome = gridcraft_ui_egui::Chrome { title_bar: false, file_tab: false },
"notitlebar" => app.chrome.title_bar = false,
"nofiletab" => app.chrome.file_tab = false,
_ => {}
}
}
// Follow the browser's language, except a CJK one. CJK coverage comes from
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
// cannot be assumed to render localized labels without tofu. Users can still
// choose a language explicitly.
let browser = gridcraft_ui_egui::i18n::Language::system();
if !matches!(
browser,
@@ -156,8 +170,9 @@ impl eframe::App for WebShell {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
/// Forwards a browser frame's raw input to the shared app before eframe handles it.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -214,7 +229,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
if let Some(w) = web_sys::window()
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
let _ = w.open_with_url_and_target(&url, "_blank");
// 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. 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),
@@ -350,7 +368,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
if let Some(picker_fn) = show_picker_fn {
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
+11 -2
View File
@@ -13,7 +13,7 @@ workspace = true
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -24,10 +24,19 @@ chrono = { workspace = true }
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
gridcraft-mcp = { workspace = true }
# The control channel's token (OS random numbers); already in the tree.
getrandom = "0.4"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
+2 -1
View File
@@ -43,7 +43,8 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
app.open_path(p);
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
Event::Quit => app.request_quit(ctx),
}
}
}
+289 -10
View File
@@ -1,40 +1,156 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
//!
//! The first line of a connection must authenticate with the app's token (#777; see
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
//! JSON, is answered once and closed.
//!
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::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, 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;
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
/// 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.
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
if t.len() < control_auth::MIN_TOKEN_LEN {
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
}
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
return Ok(t);
}
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
let token = control_auth::hex(&bytes);
let path = control_auth::write_token_file(port, &token)?;
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
Ok(token)
}
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let rx = serve_listener(listener, ctx)?;
let token: Arc<str> = make_token(port, chosen_token)?.into();
let rx = serve_listener(listener, token, ctx)?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
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 _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
let token = Arc::clone(&token);
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
/// `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 _ = 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> {
@@ -103,10 +219,16 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, 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);
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();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
@@ -154,21 +276,29 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// Send `input` on a new connection and read reply lines until the server closes it.
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
/// Authenticate a new connection, send `input`, and read reply lines (after the
/// authentication reply) until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
c.write_all(input).unwrap();
// A read timeout ends the list too: the server kept the connection open.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
let mut replies: Vec<Json> = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
replies.remove(0);
replies
}
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, egui::Context::default()).unwrap())
(addr, serve_listener(listener, TOKEN.into(), egui::Context::default()).unwrap())
}
/// A browser can POST to the port: its request line closes the connection before the JSON
@@ -194,8 +324,11 @@ mod tests {
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
let mut r = BufReader::new(c);
let mut auth_reply = String::new();
r.read_line(&mut auth_reply).unwrap();
for id in [1, 2] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
@@ -221,4 +354,150 @@ mod tests {
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// A server on a loopback port with a stand-in UI that echoes each method.
fn spawn() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
}
});
addr
}
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(send.as_bytes()).unwrap();
// Replies up to the server closing the connection.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
}
#[test]
fn the_token_opens_the_connection() {
let addr = spawn();
let auth = control_auth::auth_line(1, TOKEN);
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
line.clear();
r.read_line(&mut line).unwrap();
let v: Json = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
}
#[test]
fn anything_but_the_token_first_closes_the_connection() {
let addr = spawn();
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
// The second request is never served: one refusal, then end of stream.
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
assert_eq!(got.len(), 1, "{first}: {got:?}");
assert_eq!(got[0]["ok"], json!(false), "{first}");
}
}
#[test]
fn an_unparsable_line_after_auth_closes_the_connection() {
let addr = spawn();
let auth = control_auth::auth_line(1, TOKEN);
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
assert_eq!(got.len(), 2, "{got:?}");
assert_eq!(got[1]["ok"], json!(false));
}
/// 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());
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
}
}
+5 -303
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());
@@ -295,303 +296,4 @@ pub fn install() -> Option<&'static AppLogger> {
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
use log::Log;
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("gridcraft"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace);
assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug);
assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"gridcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,gridcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "gridcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"),
"2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2");
assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "gridcraft", "before the settings directory");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Error, "gridcraft", "internal error: boom");
record(&logger, log::Level::Debug, "gridcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("gridcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}"));
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
record(&logger, log::Level::Error, "x", "still fine");
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
record(&logger, log::Level::Warn, "gridcraft", "after the poison");
assert!(read(&path).contains("gridcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
}
mod tests;
+298
View File
@@ -0,0 +1,298 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
use log::Log;
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("gridcraft"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info);
assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace);
assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug);
assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"gridcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,gridcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "gridcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"),
"2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2");
assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "gridcraft", "before the settings directory");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Error, "gridcraft", "internal error: boom");
record(&logger, log::Level::Debug, "gridcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("gridcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}"));
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
record(&logger, log::Level::Error, "x", "still fine");
// Nothing waits in memory for a file that won't come.
assert!(logger.sink.lock().expect("sink").pending.is_empty());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
record(&logger, log::Level::Warn, "gridcraft", "after the poison");
assert!(read(&path).contains("gridcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
+52 -18
View File
@@ -1,9 +1,10 @@
//! GridCraft desktop app.
//!
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
//!
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -22,31 +23,34 @@ use std::cell::RefCell;
use std::rc::Rc;
use gridcraft_engine::Session;
use gridcraft_ui_egui::doc_windows::DocWindows;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events.
struct App(SheetApp, DocWindows, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(ctx));
if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.2 = Some(native_menu::NativeMenu::install(ctx));
}
if let Some(m) = &self.1 {
m.poll(&mut self.0, ctx);
if let Some(m) = &mut self.2 {
m.poll(&mut self.0, ctx, self.1.focused_viewport());
}
apple_events::poll(&self.2, &mut self.0, ctx);
apple_events::poll(&self.3, &mut self.0, ctx);
}
self.0.logic(ctx);
self.0.poll_printing(ctx);
self.1.logic(&mut self.0, ctx);
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forwards raw input to the shared app, then opens every non-empty dropped file path as a workbook.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
// Files dropped on the window open as workbooks.
for f in std::mem::take(&mut raw.dropped_files) {
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
@@ -55,7 +59,7 @@ impl eframe::App for App {
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.1.show(&mut self.0, ui);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
@@ -180,11 +184,11 @@ fn services() -> Services {
})),
pick_save: Some(Box::new(|suggested: &str| {
let path = std::path::Path::new(suggested);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
const TYPES: [(&str, &str); 4] =
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
@@ -251,11 +255,20 @@ 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(&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);
}
return Ok(());
}
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
let mut sample: Option<String> = None;
let mut files: Vec<String> = Vec::new();
let mut i = 0;
@@ -265,6 +278,10 @@ fn main() -> eframe::Result<()> {
i += 1;
control_port = args.get(i).and_then(|p| p.parse().ok());
}
"--control-token" => {
i += 1;
control_token = args.get(i).cloned();
}
"--sample" => {
i += 1;
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
@@ -294,14 +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.after_engine(); // Show warnings from the sample.
app.start_update_check();
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.
@@ -320,7 +339,20 @@ fn main() -> eframe::Result<()> {
if let Some(i) = icon() {
viewport = viewport.with_icon(i);
}
let window_builder = viewport.clone();
let options = eframe::NativeOptions { viewport, ..Default::default() };
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
#[cfg(target_os = "windows")]
@@ -352,13 +384,15 @@ fn main() -> eframe::Result<()> {
};
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
if let Some(port) = control_port {
match control_server::start(port, cc.egui_ctx.clone()) {
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => log::error!("control channel on port {port}: {e}"),
}
}
let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window());
Ok(Box::new(App(
app,
windows,
#[cfg(target_os = "macos")]
None,
#[cfg(target_os = "macos")]
+88 -13
View File
@@ -4,7 +4,7 @@
use std::str::FromStr;
use std::sync::mpsc::{Receiver, channel};
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::{OpenWindow, SheetApp};
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::{Value, json};
@@ -168,10 +168,20 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Calculate Now", "formulas.calculateNow", None),
],
),
("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]),
// The open workbooks follow the separator (`NativeMenu::sync_windows`).
(
"Window",
vec![
("New Window", "view.newWindow", None),
("Next Sheet", "sheet.next", None),
("Previous Sheet", "sheet.previous", None),
("-", "", None),
],
),
(
"Help",
vec![
("Check for Updates…", "app.checkForUpdates", None),
("Agent Control (MCP)…", "dialog:agents", None),
("Contributors…", "dialog:about:Contributors", None),
("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
@@ -185,8 +195,19 @@ pub struct NativeMenu {
_menu: Menu,
events: Receiver<MenuEvent>,
theme_items: Vec<(&'static str, CheckMenuItem)>,
window_menu: Option<Submenu>,
/// The Window menu's window items and what they last showed.
window_items: Vec<CheckMenuItem>,
windows: Vec<OpenWindow>,
}
/// Menu id of the Window menu item that brings workbook `doc` forward.
const WINDOW_ITEM: &str = "window:id:";
/// The Quit item: asks about each unsaved workbook in its window first
/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip.
const QUIT: &str = "app.quit";
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
let mode = match id {
"theme:system" => "system",
@@ -212,7 +233,7 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()),
]);
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
@@ -247,7 +268,7 @@ impl NativeMenu {
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
// the submenu up in the installed main menu.
if let Some(window) = window_menu {
if let Some(window) = &window_menu {
window.set_as_windows_menu_for_nsapp();
}
let (tx, rx) = channel();
@@ -256,12 +277,54 @@ impl NativeMenu {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeMenu { _menu: menu, events: rx, theme_items }
NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() }
}
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
/// Keeps the Window menu's list of open windows (title, unsaved bullet, active check) in
/// step with the session.
fn sync_windows(&mut self, app: &SheetApp) {
let windows = app.open_windows();
if windows != self.windows
&& let Some(menu) = &self.window_menu
{
let same_windows = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.window == b.window);
if same_windows {
for (item, w) in self.window_items.iter().zip(&windows) {
item.set_text(w.label());
}
} else {
for item in self.window_items.drain(..) {
let _ = menu.remove(&item);
}
for w in &windows {
let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.window), w.label(), true, w.active, None);
let _ = menu.append(&item);
self.window_items.push(item);
}
}
self.windows = windows;
}
// AppKit flips a check item when it is chosen, so the active check is put back every poll.
for (item, w) in self.window_items.iter().zip(&self.windows) {
if item.is_checked() != w.active {
item.set_checked(w.active);
}
}
}
/// Runs the chosen menu items. Commands go to the active window, the most recently focused;
/// `viewport` is its egui viewport, for the items that act on its input.
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) {
while let Ok(ev) = self.events.try_recv() {
let id = ev.id.as_ref().to_string();
if id == QUIT {
app.request_quit(ctx);
continue;
}
if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::<u64>().ok()) {
app.run_or_alert("view.switchWindows", json!({"window": window}));
continue;
}
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
if app.text_box_editor.is_some() {
let clipboard = match id.as_str() {
@@ -271,11 +334,11 @@ impl NativeMenu {
_ => None,
};
if let Some(command) = clipboard {
ctx.send_viewport_cmd(command);
ctx.send_viewport_cmd_to(viewport, command);
continue;
}
if id == "edit.undo" || id == "edit.redo" {
ctx.input_mut(|i| {
ctx.input_mut_for(viewport, |i| {
i.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
@@ -321,31 +384,43 @@ impl NativeMenu {
item.set_checked(checked);
}
}
self.sync_windows(app);
}
}
#[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");
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
}
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
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 = 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");
}
}
+7 -3
View File
@@ -1,11 +1,15 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
@@ -17,7 +21,7 @@ pub fn discover() -> Discover {
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
@@ -45,7 +49,7 @@ pub fn submit() -> Submit {
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
+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");
}
+5
View File
@@ -10,3 +10,8 @@ disallowed-methods = [
{ path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" },
{ path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" },
]
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+10
View File
@@ -99,3 +99,13 @@ real_name = "Tyrel Haveman"
[people.helenkwok]
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
+120 -1418
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
/// The aggregates that can fold a reference without building an array.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Agg {
Sum,
Count,
Average,
}
impl Agg {
pub(super) fn of(name: &str) -> Option<Agg> {
match name {
"SUM" => Some(Agg::Sum),
"COUNT" => Some(Agg::Count),
"AVERAGE" => Some(Agg::Average),
_ => None,
}
}
}
/// What a folded aggregate carries from one value to the next.
#[derive(Default)]
pub(super) struct Acc {
pub(super) sum: f64,
pub(super) count: u64,
}
impl Acc {
/// Takes a value found inside a reference: only numbers count. `Err` is an error value that
/// ends `SUM` and `AVERAGE`; `COUNT` skips errors.
pub(super) fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> {
match v {
Value::Number(n) => {
self.sum += n;
self.count = self.count.saturating_add(1);
Ok(())
}
Value::Error(e) if kind != Agg::Count => Err(*e),
_ => Ok(()),
}
}
pub(super) fn finish(&self, kind: Agg) -> Value {
match kind {
Agg::Sum => Value::number(self.sum),
Agg::Count => Value::number(self.count as f64),
Agg::Average if self.count == 0 => Value::Error(CellError::Div0),
Agg::Average => Value::number(self.sum / self.count as f64),
}
}
}
// ---------------------------------------------------------------- operators
pub(super) fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value {
match v {
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }),
Value::Error(e) => Value::Error(*e),
Value::Text(_) if plus => v.clone(),
other => match other.to_number_in(sys) {
Ok(n) => f(n),
Err(e) => Value::Error(e),
},
}
}
/// Element-wise combination with Excel's broadcasting.
pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value {
match (a, b) {
(Value::Array(x), Value::Array(y)) => {
let rows = broadcast_dim(x.rows, y.rows);
let cols = broadcast_dim(x.cols, y.cols);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
}
}
Value::from(Array { rows, cols, data })
}
(Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }),
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }),
(x, y) => f(x, y),
}
}
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
}
fn broadcast_dim(a: usize, b: usize) -> usize {
if a == 1 {
b
} else if b == 1 {
a
} else {
a.max(b)
}
}
pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value {
let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) };
let (tr, tc) = dims(t);
let (fr, fc) = dims(f);
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for col in 0..cols {
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
}
}
Value::from(Array { rows, cols, data })
}
/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
pub(super) fn single(v: Value) -> Option<Value> {
match v {
Value::Array(a) if a.rows * a.cols > 1 => None,
v => Some(v.scalar()),
}
}
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
if let Value::Error(e) = a {
return Value::Error(*e);
}
if let Value::Error(e) = b {
return Value::Error(*e);
}
let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) {
(Ok(x), Ok(y)) => f(x, y),
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
use std::cmp::Ordering::*;
match op {
BinOp::Add => num(|x, y| Value::number(x + y)),
BinOp::Sub => num(|x, y| Value::number(x - y)),
BinOp::Mul => num(|x, y| Value::number(x * y)),
BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }),
BinOp::Pow => num(|x, y| {
if x == 0.0 && y == 0.0 {
Value::Error(CellError::Num)
} else if x == 0.0 && y < 0.0 {
Value::Error(CellError::Div0)
} else {
Value::number(x.powf(y))
}
}),
BinOp::Concat => match (a.to_text(), b.to_text()) {
(Ok(x), Ok(y)) => {
if x.len() + y.len() > 32767 * 4 {
Value::Error(CellError::Value)
} else {
Value::text(x + &y)
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
},
BinOp::Eq => Value::Bool(compare(a, b) == Equal),
BinOp::Ne => Value::Bool(compare(a, b) != Equal),
BinOp::Lt => Value::Bool(compare(a, b) == Less),
BinOp::Gt => Value::Bool(compare(a, b) == Greater),
BinOp::Le => Value::Bool(compare(a, b) != Greater),
BinOp::Ge => Value::Bool(compare(a, b) != Less),
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
}
}
+435
View File
@@ -0,0 +1,435 @@
use super::array::{Acc, Agg, zip2, zip3};
use super::format::same_kind;
use super::special::{ev_shape, resized_sum_range, resizes_sum_range};
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {
match name {
"IF" => self.if_call(args),
"IFS" => {
if args.len() < 2 || !args.len().is_multiple_of(2) {
return err(CellError::Value);
}
for pair in args.chunks(2) {
let [c, v] = pair else { break };
let cv = self.value(c);
match cv.scalar().to_bool() {
Ok(true) => return self.eval(v),
Ok(false) => {}
Err(e) => return err(e),
}
}
err(CellError::NA)
}
"IFERROR" | "IFNA" => self.if_error_call(name, args),
"CHOOSE" => self.choose_call(args),
"SWITCH" => self.switch_call(args),
"ROW" | "COLUMN" => self.row_column_call(name, args),
"ROWS" | "COLUMNS" => {
if args.len() != 1 {
return err(CellError::Value);
}
let is_rows = name == "ROWS";
match self.eval(&args[0]) {
Ev::R(a) => match a.as_slice() {
[a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)),
_ => err(CellError::Ref),
},
Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)),
Ev::V(Value::Error(e)) => err(e),
Ev::V(_) => Ev::V(Value::Number(1.0)),
Ev::L(_) => err(CellError::Value),
}
}
"AREAS" => match args.first().map(|a| self.eval(a)) {
Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)),
Some(Ev::V(Value::Error(e))) => err(e),
_ => err(CellError::Value),
},
"ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))),
"ISFORMULA" | "FORMULATEXT" => {
let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) };
let Some(a) = a.first() else { return err(CellError::NA) };
let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone());
if name == "ISFORMULA" {
Ev::V(Value::Bool(f.is_some()))
} else {
match f {
Some(f) => Ev::V(Value::text(format!("={}", f.text))),
None => err(CellError::NA),
}
}
}
"SHEET" | "SHEETS" => self.sheet_call(name, args),
"OFFSET" => self.offset(args),
"INDIRECT" => self.indirect(args),
"INDEX" => self.index(args),
"XLOOKUP" => self.xlookup(args),
"CELL" => self.cell_info(args),
"TRIMRANGE" => self.trimrange(args),
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
"TEXT" => {
if args.len() != 2 {
return err(CellError::Value);
}
let v = self.arg_val(args, 0).unwrap_or_default();
let f = self.arg_val(args, 1).unwrap_or_default();
let sys = self.host.workbook().date_system;
let one = |v: &Value, f: &Value| -> Value {
if let Value::Error(e) = v {
return Value::Error(*e);
}
let code = match f.to_text() {
Ok(c) => c,
Err(e) => return Value::Error(e),
};
match gridcraft_numfmt::text_function(v, &code, sys) {
Ok(s) => Value::text(s),
Err(e) => Value::Error(e),
}
};
Ev::V(zip2(&v, &f, one))
}
"LET" => self.let_(args),
"LAMBDA" => {
if args.is_empty() {
return err(CellError::Value);
}
let mut params = Vec::new();
for p in &args[..args.len() - 1] {
match p {
Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()),
_ => return err(CellError::Value),
}
}
let Some(body) = args.last() else { return err(CellError::Value) };
Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() }))
}
"ISOMITTED" => {
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
Ev::V(Value::Bool(r))
}
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
"GROUPBY" | "PIVOTBY" => {
// The aggregation may be a function given by name (`SUM`): pass it as its name.
let i = if name == "GROUPBY" { 2 } else { 3 };
match args.get(i).and_then(|a| self.function_by_name(a)) {
Some(f) => {
let mut args = args.to_vec();
if let Some(a) = args.get_mut(i) {
*a = Expr::Text(f.into());
}
self.builtin(name, &args)
}
None => self.builtin(name, args),
}
}
"NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => {
self.host.note_volatile();
self.builtin(name, args)
}
_ => self.builtin(name, args),
}
}
fn if_call(&mut self, args: &[Expr]) -> Ev {
if args.is_empty() || args.len() > 3 {
return err(CellError::Value);
}
let condition = self.arg_val(args, 0).unwrap_or_default();
if let Value::Array(array) = &condition {
let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() {
Ok(true) => when_true.clone(),
Ok(false) => when_false.clone(),
Err(error) => Value::Error(error),
}));
}
match condition.to_bool() {
Ok(true) => match args.get(1) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(expression) => self.eval(expression),
None => Ev::V(Value::Bool(true)),
},
Ok(false) => match args.get(2) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(expression) => self.eval(expression),
None => Ev::V(Value::Bool(false)),
},
Err(error) => err(error),
}
}
fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev {
if args.len() != 2 {
return err(CellError::Value);
}
let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty));
let na_only = name == "IFNA";
let catches = |error: CellError| !na_only || error == CellError::NA;
match &value {
Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]),
Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
let alternate = self.arg_val(args, 1).unwrap_or_default();
let data =
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
Ev::R(_) => self.if_error_reference(value.clone(), args, catches),
_ => value,
}
}
fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev {
match self.deref(value.clone()) {
Value::Error(error) if catches(error) => self.eval(&args[1]),
Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
let alternate = self.arg_val(args, 1).unwrap_or_default();
let data =
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
}
_ => value,
}
}
fn choose_call(&mut self, args: &[Expr]) -> Ev {
if args.len() < 2 {
return err(CellError::Value);
}
let index = self.value(&args[0]);
if let Value::Array(_) = index {
let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) };
let mut evaluated = vec![Arg { value: index, from_ref: false }];
for argument in &args[1..] {
evaluated.push(Arg { value: self.value(argument), from_ref: false });
}
let mut context = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V);
}
let index = match index.to_number() {
Ok(number) => number.trunc() as i64,
Err(error) => return err(error),
};
if index < 1 || index as usize >= args.len() {
return err(CellError::Value);
}
self.eval(&args[index as usize])
}
fn switch_call(&mut self, args: &[Expr]) -> Ev {
if args.len() < 3 {
return err(CellError::Value);
}
let value = self.arg_val(args, 0).unwrap_or_default().scalar();
if let Value::Error(error) = value {
return err(error);
}
let choices = &args[1..];
for pair in choices.as_chunks::<2>().0 {
let (candidate, result) = (&pair[0], &pair[1]);
let candidate = self.value(candidate).scalar();
if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) {
return self.eval(result);
}
}
if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) {
self.eval(default)
} else {
err(CellError::NA)
}
}
fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev {
let is_row = name == "ROW";
if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) {
return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64));
}
let areas = match self.eval(&args[0]) {
Ev::R(areas) => areas,
Ev::V(Value::Error(error)) => return err(error),
_ => return err(CellError::Value),
};
let Some(area) = areas.first() else { return err(CellError::Ref) };
let range = self.trim_lines(area);
if is_row {
let values: Vec<Value> = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect();
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) }
} else {
let values: Vec<Value> = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect();
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) }
}
}
fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev {
let is_sheet = name == "SHEET";
if args.is_empty() {
let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() };
return Ev::V(Value::number(count as f64));
}
match self.eval(&args[0]) {
Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))),
Ev::R(areas) => {
let mut sheets: Vec<usize> = areas.iter().map(|area| area.sheet).collect();
sheets.dedup();
Ev::V(Value::number(sheets.len() as f64))
}
Ev::V(Value::Text(name)) if is_sheet => {
self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0)))
}
_ => err(CellError::NA),
}
}
pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
let Some(spec) = gridcraft_functions::lookup(name) else {
// A defined name holding a LAMBDA, or a LET-bound lambda.
return match self.name(name) {
Ev::L(l) => self.apply_lambda(&l, args),
Ev::V(Value::Error(x)) => err(x),
_ => err(CellError::Name),
};
};
if args.len() < spec.min_args || args.len() > spec.max_args {
return err(CellError::Value);
}
if let Some(kind) = Agg::of(spec.name) {
return self.aggregate(kind, args);
}
let mut evaluated = Vec::with_capacity(args.len());
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
let mut criteria_shape = None;
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
let mut criteria = None;
// GROUPBY/PIVOTBY: the LAMBDA given as their function.
let mut lambda = None;
let lambda_at = match name {
"GROUPBY" => Some(2),
"PIVOTBY" => Some(3),
_ => None,
};
for (i, a) in args.iter().enumerate() {
let mut ev = self.eval(a);
if lambda_at == Some(i)
&& let Ev::L(l) = &ev
{
lambda = Some(l.clone());
ev = Ev::V(Value::Empty);
}
if spec.category == gridcraft_functions::Category::Database
&& i == 2
&& let Ev::R(areas) = &ev
&& let [area] = areas.as_slice()
{
criteria = Some(*area);
}
if resizes_sum_range(name, args.len()) {
if i == 0 {
criteria_shape = ev_shape(&ev);
} else if i == 2
&& let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev)
&& let [area] = areas.as_slice()
{
ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]);
}
}
let from_ref = matches!(ev, Ev::R(_));
let value = match ev {
Ev::R(areas) if areas.len() > 1 => {
// Multi-area references (unions): flatten into one row for aggregates.
let mut vals = Vec::new();
for ar in &areas {
match self.area_value(ar) {
Value::Array(a) => vals.extend(a.data.iter().cloned()),
v => vals.push(v),
}
}
Value::from(Array::row(vals))
}
ev => self.deref(ev),
};
evaluated.push(Arg { value, from_ref });
}
let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
/// Performs aggregation operations without copying its ranges: blocks are folded over the stored
/// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one
/// after another, as the array path does, so volatile formulas (like `RAND`) see the same order.
/// Only `SUM`, `COUNT` and `AVG` are supported currently.
fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev {
let mut acc = Acc::default();
let mut first_error = None;
for a in args {
let ev = self.eval(a);
let steps = match ev {
Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(),
ev => {
let value = self.deref(ev);
vec![self.aggregate_value(kind, value, false, &mut acc)]
}
};
if first_error.is_none() {
first_error = steps.into_iter().find_map(Result::err);
}
}
first_error.unwrap_or_else(|| Ev::V(acc.finish(kind)))
}
/// Adds one area to `acc`; `Err` is the result the function ends with.
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
if !a.range.is_single() {
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
Err(e) => {
error = Some(e);
false
}
});
if walked {
return error.map_or(Ok(()), |e| Err(err(e)));
}
}
let value = self.area_value(a);
self.aggregate_value(kind, value, true, acc)
}
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
return Err(err(CellError::Name));
};
let args = [Arg { value, from_ref }];
if kind != Agg::Count {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.sum += n,
other => return Err(Ev::V(other)),
}
}
if kind != Agg::Sum {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
other => return Err(Ev::V(other)),
}
}
Ok(())
}
/// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`,
/// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has
/// that name.
pub(super) fn function_by_name(&self, e: &Expr) -> Option<String> {
let Expr::Name(n) = e else { return None };
if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() {
return None;
}
let u = n.to_ascii_uppercase();
gridcraft_functions::lookup(&u).map(|_| u)
}
}
+75
View File
@@ -0,0 +1,75 @@
use super::{CellError, Value};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
/// CELL("format") code for a number format.
pub(super) fn cell_format_code(code: &str) -> String {
let c = code.to_ascii_lowercase();
if c == "general" {
return "G".into();
}
if c.contains('%') {
return format!("P{}", decimals(&c));
}
if c.contains('e') && c.contains('0') {
return format!("S{}", decimals(&c));
}
if c.contains('$') {
return format!("C{}", decimals(&c));
}
if c.contains('y') || c.contains('d') {
return "D1".into();
}
if c.contains('h') {
return "D9".into();
}
if c.contains(',') {
return format!(",{}", decimals(&c));
}
format!("F{}", decimals(&c))
}
fn decimals(c: &str) -> usize {
c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count())
}
pub(super) fn same_kind(a: &Value, b: &Value) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty())
}
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is
/// not R1C1; `Err` means it is R1C1 but points past the sheet.
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result<Option<String>, CellError> {
let (sheet, body) = match s.rsplit_once('!') {
Some((sh, b)) => (format!("{sh}!"), b),
None => (String::new(), s),
};
let conv = |part: &str| -> Result<Option<String>, CellError> {
let u = part.to_ascii_uppercase();
let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) };
// `None`: not R1C1. `Some(None)`: R1C1, but past the sheet.
let num = |p: &str, base: u32, max: u32| -> Option<Option<(u32, bool)>> {
let (v, absolute) = if p.is_empty() {
(Some(i64::from(base)), false)
} else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
(i64::from(base).checked_add(inner.parse().ok()?), false)
} else {
(p.parse::<u32>().ok()?.checked_sub(1).map(i64::from), true)
};
Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute)))
};
let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) };
let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) };
Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)))
};
let out = match body.split_once(':') {
Some((a, b)) => match (conv(a)?, conv(b)?) {
(Some(a), Some(b)) => format!("{a}:{b}"),
_ => return Ok(None),
},
None => match conv(body)? {
Some(x) => x,
None => return Ok(None),
},
};
Ok(Some(format!("{sheet}{out}")))
}
+200
View File
@@ -0,0 +1,200 @@
use super::MAX_LAMBDA_CALLS;
use super::array::{broadcast_len, single};
use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
// Too many or too few argument positions is #VALUE!. An explicitly empty position, as in
// `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()`
// parses to no arguments, which for a one-parameter lambda can also be read as one empty
// position, so that call still binds the parameter as omitted.
let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1);
if !positions_ok {
return err(CellError::Value);
}
let mut bound = Vec::with_capacity(l.params.len());
for (i, p) in l.params.iter().enumerate() {
let v = match args.get(i) {
Some(Expr::Missing) | None => Ev::V(Value::Empty),
Some(e) => self.eval(e),
};
bound.push((p.clone(), v));
}
self.call_lambda_values(l, bound)
}
pub(super) fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
if self.calls >= MAX_LAMBDA_CALLS {
return err(CellError::Num);
}
// #2382: one charge per invocation, so a cell's total work stays bounded however the
// lambdas nest. Foregrounding the charge here means every path into a body (direct
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
self.fuel = match self.fuel.checked_sub(1) {
Some(f) => f,
None => return err(CellError::Calc),
};
self.calls += 1;
let saved = std::mem::replace(&mut self.env, l.env.clone());
self.env.extend(bound);
let r = self.eval(&l.body);
self.env = saved;
self.calls -= 1;
r
}
pub(super) fn let_(&mut self, args: &[Expr]) -> Ev {
if args.len() < 3 || args.len().is_multiple_of(2) {
return err(CellError::Value);
}
let base = self.env.len();
for pair in args[..args.len() - 1].chunks(2) {
let [Expr::Name(n), v] = pair else {
self.env.truncate(base);
return err(CellError::Value);
};
let val = self.eval(v);
self.env.push((n.clone(), val));
}
let r = match args.last() {
Some(body) => self.eval(body),
None => err(CellError::Value),
};
self.env.truncate(base);
r
}
/// The function argument of a LAMBDA helper; a function given by name takes `arity`
/// arguments.
fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option<Arc<Lambda>> {
if let Some(f) = e.and_then(|x| self.function_by_name(x)) {
let params: Vec<String> = (0..arity).map(|i| format!("_xleta.{i}")).collect();
let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect());
return Some(Arc::new(Lambda { params, body, env: Vec::new() }));
}
match e.map(|x| self.eval(x)) {
Some(Ev::L(l)) => Some(l),
_ => None,
}
}
pub(super) fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev {
let as_array = |v: Value| -> Arc<Array> {
match v {
Value::Array(a) => a,
other => Arc::new(Array::scalar(other)),
}
};
match name {
"MAP" => {
if args.len() < 2 {
return err(CellError::Value);
}
let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) };
let arrays: Vec<Arc<Array>> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) };
// The LAMBDA takes one parameter per array.
if l.params.len() != arrays.len() {
return err(CellError::Value);
}
let mut out = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect();
let v = self.call_lambda_values(&l, bound);
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
}
"REDUCE" | "SCAN" => {
if args.len() != 3 {
return err(CellError::Value);
}
let mut acc = self.value(&args[0]);
let arr = as_array(self.value(&args[1]));
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
if l.params.len() != 2 {
return err(CellError::Value);
}
let mut scan = Vec::with_capacity(arr.data.len());
for v in arr.data.iter() {
let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))];
let r = self.call_lambda_values(&l, bound);
acc = self.deref(r);
if name == "SCAN" {
let Some(one) = single(acc.clone()) else { return err(CellError::Calc) };
scan.push(one);
}
}
if name == "SCAN" {
Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
} else {
Ev::V(acc)
}
}
"BYROW" | "BYCOL" => {
if args.len() != 2 {
return err(CellError::Value);
}
let arr = as_array(self.value(&args[0]));
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
if l.params.len() != 1 {
return err(CellError::Value);
}
let by_row = name == "BYROW";
let n = if by_row { arr.rows } else { arr.cols };
let mut out = Vec::with_capacity(n);
for i in 0..n {
let slice: Vec<Value> = if by_row {
(0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect()
} else {
(0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect()
};
let a = if by_row { Array::row(slice) } else { Array::column(slice) };
let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]);
let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) };
out.push(one);
}
let a = if by_row { Array::column(out) } else { Array::row(out) };
Ev::V(a.into())
}
"MAKEARRAY" => {
if args.len() != 3 {
return err(CellError::Value);
}
let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) {
(Ok(r), Ok(c)) => (r.trunc(), c.trunc()),
(Err(e), _) | (_, Err(e)) => return err(e),
};
if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 {
return err(CellError::Value);
}
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
if l.params.len() != 2 {
return err(CellError::Value);
}
let (rows, cols) = (rows as usize, cols as usize);
let mut out = Vec::with_capacity(rows * cols);
for r in 0..rows {
for c in 0..cols {
let bound = vec![
(l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))),
(l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))),
];
let v = self.call_lambda_values(&l, bound);
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
}
_ => err(CellError::Name),
}
}
}
+409
View File
@@ -0,0 +1,409 @@
use super::format::{cell_format_code, r1c1_to_a1};
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use std::sync::Arc;
impl<'h> Evaluator<'h> {
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
pub(super) fn trim_lines(&mut self, a: &Area) -> RangeRef {
if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range }
}
pub(super) fn offset(&mut self, args: &[Expr]) -> Ev {
self.host.note_volatile();
if args.len() < 3 || args.len() > 5 {
return err(CellError::Value);
}
let base = match self.eval(&args[0]) {
Ev::R(a) if a.len() == 1 => a[0],
Ev::V(Value::Error(e)) => return err(e),
_ => return err(CellError::Value),
};
let rows = match self.arg_num(args, 1, 0.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let cols = match self.arg_num(args, 2, 0.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let h = match self.arg_num(args, 3, base.range.height() as f64) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let w = match self.arg_num(args, 4, base.range.width() as f64) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
if h == 0 || w == 0 {
return err(CellError::Ref);
}
let r0 = (base.range.start.row as i64).saturating_add(rows);
let c0 = (base.range.start.col as i64).saturating_add(cols);
let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) };
let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) };
if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 {
return err(CellError::Ref);
}
Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }])
}
pub(super) fn indirect(&mut self, args: &[Expr]) -> Ev {
self.host.note_volatile();
if args.is_empty() || args.len() > 2 {
return err(CellError::Value);
}
let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t,
Some(Err(e)) => return err(e),
None => return err(CellError::Value),
};
let a1 = match args.get(1) {
None | Some(Expr::Missing) => true,
Some(e) => {
let v = self.value(e);
v.scalar().to_bool().unwrap_or(true)
}
};
// With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference.
let (text, a1_ok) = if a1 {
(text, true)
} else {
match r1c1_to_a1(&text, self.at) {
Ok(Some(a1_text)) => (a1_text, true),
Ok(None) => (text, false),
Err(e) => return err(e),
}
};
match gridcraft_formula::parse(&text) {
Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref),
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
r @ Ev::R(_) => r,
_ => err(CellError::Ref),
},
_ => err(CellError::Ref),
}
}
pub(super) fn index(&mut self, args: &[Expr]) -> Ev {
if args.is_empty() || args.len() > 4 {
return err(CellError::Value);
}
let src = self.eval(&args[0]);
let mut arg = |i: usize| match args.get(i) {
None | Some(Expr::Missing) => None,
Some(e) => Some(self.value(e)),
};
let (rows, cols) = (arg(1), arg(2));
let area_n = match self.arg_num(args, 3, 1.0) {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
let two = args.len() == 2;
let num = |v: &Option<Value>| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64);
// Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2.
if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) {
let as_array = |v: &Option<Value>| match v {
Some(Value::Array(a)) => Arc::clone(a),
Some(v) => Arc::new(Array::scalar(v.clone())),
None => Arc::new(Array::scalar(Value::Number(0.0))),
};
let (ra, ca) = (as_array(&rows), as_array(&cols));
let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols));
if (h as u64).saturating_mul(w as u64) > MAX_CELLS {
return err(CellError::Num);
}
let mut out = Vec::with_capacity(h * w);
for r in 0..h {
for c in 0..w {
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
let v = match pos {
(Ok(row), Ok(col)) => {
let one = self.index_one(src.clone(), row, col, area_n, two);
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
out.push(v);
}
}
return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value)));
}
let (row, col) = match (num(&rows), num(&cols)) {
(Ok(r), Ok(c)) => (r, c),
(Err(e), _) | (_, Err(e)) => return err(e),
};
self.index_one(src, row, col, area_n, two)
}
/// INDEX for one row and column position (0 = the whole column or row).
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
if row < 0 || col < 0 {
return err(CellError::Value);
}
match src {
Ev::R(areas) => {
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
let r = a.range;
// A single row or column with one index picks along it. An area with several rows
// and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third
// argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ:
// `INDEX({1,2;3,4},2)` is the row.
if two_args && r.height() > 1 && r.width() > 1 {
return err(CellError::Ref);
}
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
return err(CellError::Ref);
}
let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) };
let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) };
Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }])
}
Ev::V(Value::Array(arr)) => {
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
if row as usize > arr.rows || col as usize > arr.cols {
return err(CellError::Ref);
}
match (row, col) {
(0, 0) => Ev::V(Value::Array(arr)),
(0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()),
(r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()),
(r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))),
}
}
Ev::V(Value::Error(e)) => err(e),
Ev::V(v) => {
if row <= 1 && col <= 1 {
Ev::V(v)
} else {
err(CellError::Ref)
}
}
Ev::L(_) => err(CellError::Value),
}
}
/// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can
/// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of
/// lookup values, array arguments, errors) goes to the library function.
pub(super) fn xlookup(&mut self, args: &[Expr]) -> Ev {
if (3..=6).contains(&args.len())
&& let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2]))
&& let ([l], [r]) = (look.as_slice(), ret.as_slice())
&& let Some(spec) = gridcraft_functions::lookup("XMATCH")
{
let key = self.value(&args[0]);
let (l, r) = (*l, *r);
let vertical = l.range.width() == 1 && l.range.height() > 1;
let horizontal = l.range.height() == 1 && l.range.width() > 1;
let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() };
if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len {
let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }];
// An empty match_mode before a given search_mode is the default (0), not the end.
let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing));
let last = if given(5) {
5
} else if given(4) {
4
} else {
3
};
for i in 4..=last {
match args.get(i) {
Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }),
Some(e) => {
let value = self.value(e);
xargs.push(Arg { value, from_ref: false });
}
}
}
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
Value::Number(p) if p >= 1.0 => {
let k = p as u32 - 1;
let range = if vertical {
RangeRef::new(
CellRef::new(r.range.start.row + k, r.range.start.col),
CellRef::new(r.range.start.row + k, r.range.end.col),
)
} else {
RangeRef::new(
CellRef::new(r.range.start.row, r.range.start.col + k),
CellRef::new(r.range.end.row, r.range.start.col + k),
)
};
Ev::R(vec![Area { sheet: r.sheet, range }])
}
Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]),
Value::Error(e) => err(e),
_ => err(CellError::Value),
};
}
}
self.builtin("XLOOKUP", args)
}
pub(super) fn cell_info(&mut self, args: &[Expr]) -> Ev {
let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t.to_ascii_lowercase(),
Some(Err(e)) => return err(e),
None => return err(CellError::Value),
};
let area = match args.get(1).map(|a| self.eval(a)) {
Some(Ev::R(a)) => a.first().copied(),
None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }),
_ => return err(CellError::Value),
};
let Some(a) = area else { return err(CellError::Ref) };
let c = a.range.start;
let wb = self.host.workbook();
let sheet = wb.sheet(a.sheet);
match kind.as_str() {
"address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))),
"row" => Ev::V(Value::number(c.row as f64 + 1.0)),
"col" => Ev::V(Value::number(c.col as f64 + 1.0)),
"contents" => Ev::V(self.host.cell_value(a.sheet, c)),
"type" => {
let v = self.host.cell_value(a.sheet, c);
Ev::V(Value::text(match v {
Value::Empty => "b",
Value::Text(_) => "l",
_ => "v",
}))
}
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
// Excel's `<folder>/[<file>]<sheet>`, which the classic sheet-name formula splits on
// the `]`. Empty until the workbook has a file of its own, as in Excel (#1489).
"filename" => Ev::V(Value::text(match &wb.source_path {
Some(file) => {
let name = file.file_name().unwrap_or_default().to_string_lossy();
let on = sheet.map_or("", |s| s.name.as_str());
file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned()
}
None => String::new(),
})),
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
"format" => {
let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default();
Ev::V(Value::text(cell_format_code(&code)))
}
"color" | "parentheses" => Ev::V(Value::Number(0.0)),
"prefix" => Ev::V(Value::text("")),
_ => err(CellError::Value),
}
}
pub(super) fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev {
// SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1.
let min_args = if name == "AGGREGATE" { 3 } else { 2 };
if args.len() < min_args {
return err(CellError::Value);
}
let code = match self.arg_num(args, 0, 0.0) {
Ok(n) => n.trunc() as u32,
Err(e) => return err(e),
};
let (func, options, rest) = if name == "AGGREGATE" {
let opt = match self.arg_num(args, 1, 0.0) {
Ok(n) => n.trunc() as u32,
Err(e) => return err(e),
};
(code, opt, &args[2..])
} else {
let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) };
(f, if ignore_hidden { 5 } else { 0 }, &args[1..])
};
if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) {
return err(CellError::Value);
}
let skip_hidden = matches!(options, 1 | 3 | 5 | 7);
// SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's
// SUBTOTAL documentation); 101-111 also leave out rows hidden by hand.
let skip_filtered = name == "SUBTOTAL";
let skip_errors = matches!(options, 2 | 3 | 6 | 7);
// SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3.
let skip_nested = name == "SUBTOTAL" || options <= 3;
let mut values = Vec::new();
let mut k = None;
let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest };
if name == "AGGREGATE" && func >= 14 {
k = rest.get(1).map(|e| self.value(e));
}
// Every cell visited (even one skipped as hidden, nested or an error) and every value
// counts against one budget for the whole call. All arguments are evaluated (references
// stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes
// `A:XFD` trim to the whole sheet, ~17 billion cells (#2175).
enum Input {
Cells(usize, RangeRef),
Values(Value),
}
let mut inputs = Vec::new();
let mut budget = MAX_CELLS;
for e in refs {
match self.eval(e) {
Ev::R(areas) => {
for a in areas {
let range = self.trim(&a);
budget = match budget.checked_sub(range.count()) {
Some(left) => left,
None => return err(CellError::Num),
};
inputs.push(Input::Cells(a.sheet, range));
}
}
Ev::V(v) => {
let n = match &v {
Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX),
_ => 1,
};
budget = match budget.checked_sub(n) {
Some(left) => left,
None => return err(CellError::Num),
};
inputs.push(Input::Values(v));
}
Ev::L(_) => return err(CellError::Value),
}
}
for input in inputs {
match input {
Input::Cells(sheet, range) => {
for c in range.iter() {
let (hidden, nested) = {
let wb = self.host.workbook();
let s = wb.sheet(sheet);
let hidden =
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
let nested = skip_nested
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
let u = f.text.to_ascii_uppercase();
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
});
(hidden, nested)
};
if hidden || nested {
continue;
}
let v = self.host.cell_value(sheet, c);
if skip_errors && v.is_error() {
continue;
}
values.push(v);
}
}
Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
Input::Values(v) => values.push(v),
}
}
if let Some(k) = k {
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k));
}
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
}
+165
View File
@@ -0,0 +1,165 @@
use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool {
args == 3 && matches!(name, "SUMIF" | "AVERAGEIF")
}
/// Height and width of a single-area reference or an array argument.
pub(super) fn ev_shape(ev: &Ev) -> Option<(u32, u32)> {
match ev {
Ev::R(areas) => match areas.as_slice() {
[a] => Some((a.range.height(), a.range.width())),
_ => None,
},
Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))),
Ev::V(_) => Some((1, 1)),
Ev::L(_) => None,
}
}
/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns
/// as the criteria range (`height` × `width`), cut off at the sheet's edge.
pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef {
let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1);
let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1);
RangeRef::new(sum.start, CellRef::new(row, col))
}
/// Collects the static references of an expression with their sheets resolved; `dynamic` is
/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change.
pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
let mut out = Vec::new();
let mut dynamic = false;
let push = |out: &mut Vec<Area>, r: &Reference, range: RangeRef| match &r.sheet {
SheetSel::Current => out.push(Area { sheet, range }),
SheetSel::Named(n) => {
if let Some(i) = wb.sheet_index(n) {
out.push(Area { sheet: i, range });
}
}
SheetSel::Span(a, b) => {
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
for s in i.min(j)..=i.max(j) {
out.push(Area { sheet: s, range });
}
}
}
};
e.walk(&mut |x| match x {
Expr::Ref(r) => push(&mut out, r, r.range()),
// The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when
// the criteria range's size isn't known statically, recalculate the formula every time.
Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) {
(Some(Expr::Ref(c)), Some(Expr::Ref(s))) => {
let (crit, sum) = (c.range(), s.range());
push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width()));
}
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
_ => dynamic = true,
},
Expr::Call(n, _)
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
{
dynamic = true
}
Expr::Name(_) | Expr::Struct(_) => dynamic = true,
Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true,
// A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between
// the references inside it, not only those references.
Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => {
let mut inner = precedents(wb, sheet, a).0;
inner.extend(precedents(wb, sheet, b).0);
let mut boxes: Vec<Area> = Vec::new();
for area in inner {
match boxes.iter_mut().find(|x| x.sheet == area.sheet) {
Some(x) => x.range = x.range.union(&area.range),
None => boxes.push(area),
}
}
out.extend(boxes);
}
_ => {}
});
(out, dynamic)
}
fn is_special(n: &str) -> bool {
matches!(
n,
"IF" | "IFS"
| "IFERROR"
| "IFNA"
| "CHOOSE"
| "SWITCH"
| "ROW"
| "COLUMN"
| "ROWS"
| "COLUMNS"
| "AREAS"
| "ISREF"
| "ISFORMULA"
| "FORMULATEXT"
| "SHEET"
| "SHEETS"
| "OFFSET"
| "INDIRECT"
| "INDEX"
| "CELL"
| "TRIMRANGE"
| "SUBTOTAL"
| "AGGREGATE"
| "TEXT"
| "LET"
| "LAMBDA"
| "ISOMITTED"
| "MAP"
| "REDUCE"
| "SCAN"
| "BYROW"
| "BYCOL"
| "MAKEARRAY"
)
}
/// Names of functions the evaluator implements itself (merged into the function list for UI).
pub const SPECIAL_FUNCTIONS: &[&str] = &[
"IF",
"IFS",
"IFERROR",
"IFNA",
"CHOOSE",
"SWITCH",
"ROW",
"COLUMN",
"ROWS",
"COLUMNS",
"AREAS",
"ISREF",
"ISFORMULA",
"FORMULATEXT",
"SHEET",
"SHEETS",
"OFFSET",
"INDIRECT",
"INDEX",
"CELL",
"TRIMRANGE",
"SUBTOTAL",
"AGGREGATE",
"TEXT",
"LET",
"LAMBDA",
"ISOMITTED",
"MAP",
"REDUCE",
"SCAN",
"BYROW",
"BYCOL",
"MAKEARRAY",
];
pub fn is_known_function(n: &str) -> bool {
is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some()
}
+225 -957
View File
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
use super::RangeRef;
use crate::eval::Area;
use gridcraft_core::CellRef;
use std::collections::BTreeSet;
use std::hash::{BuildHasherDefault, Hasher};
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
#[derive(Default, Clone, Copy)]
pub struct FxHasher(u64);
impl Hasher for FxHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.write_u64(*b as u64);
}
}
fn write_u64(&mut self, i: u64) {
self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
}
fn write_u32(&mut self, i: u32) {
self.write_u64(i as u64);
}
fn write_usize(&mut self, i: usize) {
self.write_u64(i as u64);
}
}
pub(super) type HashMap<K, V> = std::collections::HashMap<K, V, BuildHasherDefault<FxHasher>>;
pub(super) type HashSet<K> = std::collections::HashSet<K, BuildHasherDefault<FxHasher>>;
pub type Key = (usize, CellRef);
#[derive(Clone, Debug, Default)]
pub(super) struct Node {
pub(super) areas: Vec<Area>,
pub(super) dynamic: bool,
}
/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones.
const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
/// `a..=b`?" in O(log n + k).
///
/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query
/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one
/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops
/// dead entries once either grows, so mutations cost O(log n) amortised.
#[derive(Clone, Debug)]
pub(super) struct Intervals<T> {
/// Sorted, without duplicates.
items: Vec<(u32, u32, T)>,
dead: Vec<bool>,
dead_count: usize,
/// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at
/// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included).
max_end: Vec<u32>,
/// Inserted since the last merge.
pending: BTreeSet<(u32, u32, T)>,
}
impl<T> Default for Intervals<T> {
fn default() -> Self {
Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() }
}
}
impl<T: Copy + Ord> Intervals<T> {
pub(super) fn is_empty(&self) -> bool {
self.items.len() == self.dead_count && self.pending.is_empty()
}
pub(super) fn insert(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& *d
{
*d = false;
self.dead_count -= 1;
}
return;
}
self.pending.insert(it);
}
pub(super) fn remove(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& !*d
{
*d = true;
self.dead_count += 1;
}
return;
}
self.pending.remove(&it);
}
/// Merges pending insertions and drops dead entries when there are enough of them.
pub(super) fn settle(&mut self) {
if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) {
return;
}
let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len());
all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it));
all.extend(std::mem::take(&mut self.pending));
// Two sorted runs: the stable sort merges them in linear time.
all.sort();
all.dedup();
let size = all.len().next_power_of_two();
let mut max_end = vec![0; 2 * size];
if let Some(leaves) = max_end.get_mut(size..) {
for (slot, it) in leaves.iter_mut().zip(&all) {
*slot = it.1;
}
}
for i in (1..size).rev() {
let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0));
if let Some(slot) = max_end.get_mut(i) {
*slot = m;
}
}
self.dead = vec![false; all.len()];
self.dead_count = 0;
self.items = all;
self.max_end = max_end;
}
/// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the
/// number of tree nodes visited.
pub(super) fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize {
for it in &self.pending {
if it.0 <= b && it.1 >= a {
f(&it.2);
}
}
// Entries before `p` start at or before row `b`.
let p = self.items.partition_point(|it| it.0 <= b);
if p == 0 {
return 0;
}
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
}
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return 1;
}
if hi - lo <= 1 {
if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo))
&& it.1 >= a
{
f(&it.2);
}
return 1;
}
let mid = lo + (hi - lo) / 2;
1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f)
}
}
#[derive(Clone, Debug, Default)]
pub struct Graph {
pub(super) nodes: HashMap<Key, Node>,
/// single cell → dependents
cell_deps: HashMap<Key, HashSet<Key>>,
/// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column
col_deps: HashMap<(usize, u32), Intervals<Key>>,
/// sheet → row intervals of wider ranges, with their columns: (first, last, dependent)
wide_deps: HashMap<usize, Intervals<(u32, u32, Key)>>,
pub(super) dynamic: HashSet<Key>,
/// Range indexes changed since the last [`Graph::settle`].
unsettled_cols: HashSet<(usize, u32)>,
unsettled_wide: HashSet<usize>,
}
/// A formula's precedents split by shape: single cells, column intervals and wide ranges.
type Entries = (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>);
impl Graph {
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Index entries of a formula's precedents, without duplicates: single cells, column
/// intervals and wide ranges.
fn entries(areas: &[Area]) -> Entries {
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
for a in areas {
if a.range.is_single() {
cells.push((a.sheet, a.range.start));
} else if a.range.width() > WIDE {
wide.push((a.sheet, a.range));
} else {
for c in a.range.start.col..=a.range.end.col {
cols.push((a.sheet, c, a.range.start.row, a.range.end.row));
}
}
}
cells.sort_unstable();
cells.dedup();
cols.sort_unstable();
cols.dedup();
wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end));
wide.dedup();
(cells, cols, wide)
}
pub(super) fn insert(&mut self, k: Key, node: Node) {
self.remove(k);
let (cells, cols, wide) = Self::entries(&node.areas);
for c in cells {
self.cell_deps.entry(c).or_default().insert(k);
}
for (s, c, r0, r1) in cols {
self.col_deps.entry((s, c)).or_default().insert(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
for (s, r) in wide {
self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
if node.dynamic {
self.dynamic.insert(k);
}
self.nodes.insert(k, node);
}
pub(super) fn remove(&mut self, k: Key) {
let Some(old) = self.nodes.remove(&k) else { return };
let (cells, cols, wide) = Self::entries(&old.areas);
for c in cells {
if let Some(v) = self.cell_deps.get_mut(&c) {
v.remove(&k);
if v.is_empty() {
self.cell_deps.remove(&c);
}
}
}
for (s, c, r0, r1) in cols {
if let Some(v) = self.col_deps.get_mut(&(s, c)) {
v.remove(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
}
for (s, r) in wide {
if let Some(v) = self.wide_deps.get_mut(&s) {
v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
}
self.dynamic.remove(&k);
}
/// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it,
/// just slower).
pub(super) fn settle(&mut self) {
for key in std::mem::take(&mut self.unsettled_cols) {
if let Some(v) = self.col_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.col_deps.remove(&key);
}
}
}
for key in std::mem::take(&mut self.unsettled_wide) {
if let Some(v) = self.wide_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.wide_deps.remove(&key);
}
}
}
}
/// Formulas that read cell `c` of `sheet`.
pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec<Key>) {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
v.query(c.row, c.row, &mut |k| out.push(*k));
}
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(c.row, c.row, &mut |(c0, c1, k)| {
if *c0 <= c.col && c.col <= *c1 {
out.push(*k);
}
});
}
}
/// Formulas that read any cell of `range` (a formula can be listed more than once).
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
if range.count() <= 4096 {
for c in range.iter() {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
}
} else {
for ((s, c), v) in &self.cell_deps {
if *s == sheet && range.contains(*c) {
out.extend(v.iter().copied());
}
}
}
let (r0, r1) = (range.start.row, range.end.row);
if (range.width() as usize) <= self.col_deps.len() {
for col in range.start.col..=range.end.col {
if let Some(v) = self.col_deps.get(&(sheet, col)) {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
} else {
for ((s, col), v) in &self.col_deps {
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
}
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(r0, r1, &mut |(c0, c1, k)| {
if *c0 <= range.end.col && range.start.col <= *c1 {
out.push(*k);
}
});
}
}
/// Precedent areas of a formula cell (for Trace Precedents).
pub fn precedents_of(&self, k: Key) -> Vec<Area> {
self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default()
}
}
+556
View File
@@ -0,0 +1,556 @@
use super::graph::{HashMap, HashSet, Key};
use super::{Evaluator, Host, Workbook, stack_position};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate
/// whatever order they come in, with bounded stack.
const MAX_NESTED: usize = 64;
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
pub(super) struct PassHost<'a> {
wb: &'a Workbook,
exprs: &'a HashMap<Key, std::sync::Arc<Expr>>,
pub(super) results: HashMap<Key, Value>,
pending: HashSet<Key>,
in_progress: HashSet<Key>,
/// New spills found during the pass: anchor → array.
pub(super) spills: HashMap<Key, std::sync::Arc<Array>>,
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
/// every row, so once per sheet rather than once per range read.
used: HashMap<usize, Option<RangeRef>>,
rng: &'a mut u64,
now: f64,
cycle: bool,
pub(super) cycles: Vec<Key>,
depth: usize,
/// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned
/// and it is calculated first.
suspend: Option<Key>,
/// Formulas whose evaluation was abandoned for another one, to be calculated again after it.
/// They all need it, so reading one of them while it is calculated is a circular reference,
/// as reading a formula in progress is.
waiting: HashSet<Key>,
/// The formulas abandoned since the last suspension.
abandoned: Vec<Key>,
/// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They
/// are calculated along with the suspended one, rather than one per restart (a wide range
/// read at the limit would restart the abandoned evaluations once per cell in it).
also: Vec<Key>,
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
#[cfg(test)]
pub(super) suspensions: usize,
/// The formulas in `cycles`.
cycle_seen: HashSet<Key>,
/// Iterative calculation: a circular reference reads the cell's previous value instead of
/// giving `#CIRC!`.
pub(super) iterate: bool,
/// Values from the previous iteration; cells not in it read their stored value.
prev: HashMap<Key, Value>,
/// Formulas being evaluated, innermost last (kept only when iterating).
stack: Vec<Key>,
/// Formulas found on a circular reference (when iterating).
pub(super) members: HashSet<Key>,
/// Results that read a previous value, directly or through other formulas.
pub(super) tainted: HashSet<Key>,
/// Whether the formula being evaluated has read a previous value.
taint: bool,
/// `stack_position()` where the outermost formula being calculated started.
stack_top: usize,
/// Whether an evaluation ran past the stack budget since the innermost formula started.
out_of_stack: bool,
}
impl<'a> PassHost<'a> {
pub(super) fn new(wb: &'a Workbook, exprs: &'a HashMap<Key, std::sync::Arc<Expr>>, rng: &'a mut u64, now: f64) -> Self {
PassHost {
wb,
exprs,
results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()),
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng,
now,
cycle: false,
cycles: vec![],
depth: 0,
suspend: None,
waiting: HashSet::default(),
abandoned: Vec::new(),
also: Vec::new(),
#[cfg(test)]
suspensions: 0,
cycle_seen: HashSet::default(),
iterate: false,
prev: HashMap::default(),
stack: Vec::new(),
members: HashSet::default(),
tainted: HashSet::default(),
taint: false,
stack_top: 0,
out_of_stack: false,
}
}
}
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is
/// recalculated in this pass, and so may spill elsewhere now).
fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
let Some(sh) = host.wb.sheet(sheet) else { return false };
let end =
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
let range = RangeRef::new(anchor, end);
let own = sh.spill_ranges.get(&anchor);
let spilled_over = |c: CellRef| {
!own.is_some_and(|o| o.contains(c))
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
};
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.merges.iter().any(|m| m.intersects(&range))
|| sh.tables.iter().any(|t| t.range.contains(anchor))
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some()))
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
}
/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over
/// `range`: the same area, nothing new in the way, and the same values.
pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool {
let end =
CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
RangeRef::new(anchor, end) == range
&& !sheet.merges.iter().any(|m| m.intersects(&range))
&& !sheet.tables.iter().any(|t| t.range.contains(anchor))
&& range
.iter()
.enumerate()
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
}
impl PassHost<'_> {
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
/// calculated from here, then the suspended ones again (reading the results kept so far).
pub(super) fn calculate(&mut self, k: Key) {
// Formulas to calculate, innermost last, each with the ones abandoned to calculate the
// next.
let mut work: Vec<(Key, Vec<Key>)> = vec![(k, Vec::new())];
while let Some(&(top, _)) = work.last() {
self.compute(top);
match self.suspend.take() {
Some(next) => {
#[cfg(test)]
{
self.suspensions += 1;
}
let held = std::mem::take(&mut self.abandoned);
self.waiting.extend(held.iter().copied());
if let Some(entry) = work.last_mut() {
entry.1 = held;
}
// The other formulas read at the limit are calculated after `next`, before the
// held evaluations restart. Each has nothing held of its own, so the held ones
// are released only when the last of them is done.
let mut seen: HashSet<Key> = HashSet::default();
seen.insert(next);
for s in std::mem::take(&mut self.also) {
if seen.insert(s) {
work.push((s, Vec::new()));
}
}
work.push((next, Vec::new()));
}
None => {
work.pop();
if let Some(entry) = work.last_mut() {
for a in entry.1.drain(..) {
self.waiting.remove(&a);
}
}
}
}
}
}
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
let v = v.clone();
if self.iterate && self.tainted.contains(&k) {
self.taint = true;
}
return v;
}
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
if self.suspend.is_some() {
// Unwinding to `calculate`: the value is thrown away. A formula read at the limit is
// calculated with the suspended one, so the evaluations are not restarted for each.
if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) {
self.also.push(k);
}
return Value::Error(CellError::Calc);
}
if self.in_progress.contains(&k) || self.waiting.contains(&k) {
self.cycle = true;
if self.iterate {
return self.circular_read(k);
}
// Once each, though a suspended evaluation meets it again.
if self.cycle_seen.insert(k) {
self.cycles.push(k);
}
return Value::Error(CellError::Circ);
}
if self.depth >= MAX_NESTED {
self.suspend = Some(k);
return Value::Error(CellError::Calc);
}
if self.depth == 0 {
self.stack_top = stack_position();
}
self.in_progress.insert(k);
self.depth += 1;
let outer_taint = std::mem::replace(&mut self.taint, false);
if self.iterate {
self.stack.push(k);
}
let expr = std::sync::Arc::clone(expr);
let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false);
let v = {
let mut ev = Evaluator::new(self, k.0, k.1);
ev.value(&expr)
};
let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack);
if out_of_stack && self.depth > 1 && self.suspend.is_none() {
// It ran out of the stack left below the formulas that read it: it is calculated
// from the top instead, as a formula needed at `MAX_NESTED` levels deep is.
self.suspend = Some(k);
self.depth -= 1;
self.in_progress.remove(&k);
if self.iterate {
self.stack.pop();
}
self.taint = outer_taint;
return Value::Error(CellError::Calc);
}
if self.suspend.is_some() {
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
self.depth -= 1;
self.in_progress.remove(&k);
self.abandoned.push(k);
if self.iterate {
self.stack.pop();
}
self.taint = outer_taint;
return Value::Error(CellError::Calc);
}
// A legacy array formula fills the range it was entered in.
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
let v = match legacy {
Some(range) => fit_to_range(v, range),
None => v,
};
self.depth -= 1;
self.in_progress.remove(&k);
self.pending.remove(&k);
if self.iterate {
self.stack.pop();
if self.taint {
self.tainted.insert(k);
} else {
self.tainted.remove(&k);
}
}
self.taint |= outer_taint;
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
// of their own, which stay blank.
let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY"));
let v = match v {
Value::Array(a) => {
let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) {
a
} else {
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
};
// A dynamic-array formula anchors a spill range even while its result is one
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
// returns a scalar never reaches this arm, so it does not become an anchor.
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
}
Value::Empty => Value::Number(0.0),
v => v,
};
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
Value::Error(CellError::Spill)
}
_ => v,
};
self.results.insert(k, v.clone());
v
}
/// A formula's previous value for iterative calculation: from the last iteration, else the
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
fn previous(&self, k: Key) -> Value {
let v = match self.prev.get(&k) {
Some(v) => v.clone(),
None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(),
};
if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v }
}
/// Reading `k` while it is being evaluated, with iterative calculation on: its previous value.
/// The formulas around the loop are the ones whose changes decide when iteration stops.
fn circular_read(&mut self, k: Key) -> Value {
self.taint = true;
match self.stack.iter().rposition(|s| *s == k) {
Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()),
// Too deep to follow: the formula itself is watched.
None => {
self.members.insert(k);
}
}
self.previous(k)
}
/// Iterative calculation, after a first pass over `order` that met circular references. The
/// formulas that read a previous value (the loops and what depends on them) are evaluated
/// again in the same order, each pass starting from the last one's values and reading what it
/// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or
/// more, or after `max_iterations` passes in all. Returns the number of passes.
pub(super) fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 {
let again: Vec<Key> = order.iter().copied().filter(|k| self.tainted.contains(k)).collect();
let set: HashSet<Key> = again.iter().copied().collect();
// Bounds the work whatever Maximum Iterations says.
let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1);
let mut passes = 1u32;
while passes < max_iterations.min(max_passes) && !self.converged(max_change) {
self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect();
self.pending = set.clone();
self.spills.retain(|a, _| !set.contains(a));
for k in &again {
self.calculate(*k);
}
passes += 1;
}
passes
}
/// Whether every formula on a loop changed by less than `max_change` in the last pass (text,
/// logical and error values: not at all).
fn converged(&self, max_change: f64) -> bool {
self.members.iter().all(|k| {
let Some(new) = self.results.get(k) else { return true };
match (new, &self.previous(*k)) {
(Value::Number(a), Value::Number(b)) => {
let d = (a - b).abs();
d < max_change || d == 0.0
}
(new, old) => new == old,
}
})
}
}
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
/// Excel does: a single row or column repeats across the range, a single value fills it, cells
/// past the result show `#N/A`, and a result larger than the range is cut off.
fn fit_to_range(v: Value, range: RangeRef) -> Value {
if range.count() > crate::eval::MAX_CELLS {
return Value::Error(CellError::Num);
}
let (h, w) = (range.height() as usize, range.width() as usize);
let Value::Array(a) = v else {
return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) };
};
if h * w == 1 {
return a.data.first().cloned().unwrap_or_default();
}
let mut data = Vec::with_capacity(h * w);
for r in 0..h {
for c in 0..w {
let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c });
data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA)));
}
}
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
fn stack_top(&self) -> Option<usize> {
(self.depth > 1).then_some(self.stack_top)
}
fn stack_exhausted(&mut self) {
self.out_of_stack = true;
}
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
let k = (sheet, anchor);
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied();
}
// Recalculated in this pass: its new array, unless something blocks it (#SPILL!).
self.compute(k);
let arr = self.spills.get(&k)?;
let end = CellRef::new(
anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)),
anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
let range = RangeRef::new(anchor, end);
let sh = self.wb.sheet(sheet)?;
let blocked = end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty()))
|| sh.merges.iter().any(|m| m.intersects(&range));
(!blocked).then_some(range)
}
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
let k = (sheet, c);
if self.pending.contains(&k) || self.results.contains_key(&k) {
return self.compute(k);
}
// A cell covered by a spill computed in this pass.
for (anchor, arr) in &self.spills {
if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col {
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
if dr < arr.rows && dc < arr.cols {
if self.iterate && self.tainted.contains(anchor) {
self.taint = true;
}
return arr.get(dr, dc).cloned().unwrap_or_default();
}
}
}
self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default()
}
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
let (h, w) = (range.height() as usize, range.width() as usize);
let mut out = vec![Value::Empty; h * w];
let Some(sh) = self.wb.sheet(sheet) else { return out };
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
// `compute`, constants are copied straight from the store.
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
for (c, cell) in sh.cells.iter_range(range) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if cell.formula.is_some() {
formulas.push((i, c));
} else if let Some(slot) = out.get_mut(i) {
*slot = cell.value.clone();
}
}
for (c, v) in sh.spill.range(range.start..=range.end) {
if range.contains(*c) {
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
if let Some(slot) = out.get_mut(i)
&& slot.is_empty()
{
*slot = v.clone();
}
}
}
for (i, c) in formulas {
let v = self.cell_value(sheet, c);
if let Some(slot) = out.get_mut(i) {
*slot = v;
}
}
if !self.spills.is_empty() {
let new: Vec<(usize, Value)> = self
.spills
.iter()
.filter(|(a, _)| a.0 == sheet)
.flat_map(|(a, arr)| {
let mut v = Vec::new();
for r in 0..arr.rows {
for cc in 0..arr.cols {
let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32));
if c != a.1 && range.contains(c) {
v.push((
(c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize,
arr.get(r, cc).cloned().unwrap_or_default(),
));
}
}
}
v
})
.collect();
// Iterating: an array spilled by a formula that read a previous value.
if self.iterate
&& self.spills.iter().any(|(a, arr)| {
let end = CellRef::new(
a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)),
a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range)
})
{
self.taint = true;
}
for (i, v) in new {
if let Some(slot) = out.get_mut(i) {
*slot = v;
}
}
}
out
}
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
let wb = self.wb;
let Some(sh) = wb.sheet(sheet) else { return false };
// Formula cells first, in row-major order like `range_values`: reading one calculates it
// and the pass remembers the value, so the walk below gets the same value again without
// this method holding any.
for (c, cell) in sh.cells.iter_range(range) {
if cell.formula.is_some() {
let _ = self.cell_value(sheet, c);
}
}
// Spilled values sit between the stored cells (and the formulas just calculated may have
// produced some): leave such blocks to `range_values`.
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
return false;
}
for (c, cell) in sh.cells.iter_range(range) {
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
if !go {
break;
}
}
true
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
}
fn now_serial(&self) -> f64 {
self.now
}
fn random(&mut self) -> f64 {
// xorshift64*
let mut x = *self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
*self.rng = x;
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
}
+157
View File
@@ -384,6 +384,32 @@ fn circular_references_without_iterative_calculation() {
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
/// The list of circular references keeps the loops that a recalculation of other cells does not
/// reach, and drops a loop once a constant is typed over one of its cells.
#[test]
fn circular_references_stay_listed_until_the_loop_is_removed() {
let mut t = T::new();
t.set("A1", "=A1+1");
t.set("B1", "=B2");
t.set("B2", "=B1");
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
t.set("D4", "7");
t.set("D5", "=D4*2");
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
t.set("A1", "5");
assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
t.set("B2", "3");
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
// In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it.
t.wb.calc.mode = gridcraft_model::CalcMode::Manual;
t.set("C1", "=C1+1");
assert_eq!(t.calc.circular, vec![(0, c("C1"))]);
t.set("C1", "1");
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
}
#[test]
fn date_text_coerces_in_the_workbook_date_system() {
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
@@ -570,6 +596,43 @@ fn lambda_helpers_check_shapes_and_parameters() {
assert_eq!(t.num("B7"), 2.0);
}
#[test]
fn lambda_calls_spend_one_work_budget_per_cell() {
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
let mut t = T::new();
t.set(
"A1",
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
);
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
assert_eq!(t.num("A1"), 45.0);
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn 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();
@@ -1007,6 +1070,100 @@ fn lambda_argument_count() {
assert_eq!(t.get("A9"), Value::Bool(true));
}
#[test]
fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
// 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
let handle = std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(|| {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName {
name: "LOOP".into(),
scope: None,
formula: "LAMBDA(n,LOOP(n))".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
t.set("A1", endless);
t.set("A2", "=LOOP(1)");
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
})
.expect("spawn");
let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
assert_eq!(endless, Value::Error(CellError::Num));
assert_eq!(endless_name, Value::Error(CellError::Num));
assert_eq!(through_map, Value::Error(CellError::Num));
assert_eq!(evaluated, Value::Error(CellError::Num));
assert_eq!(countdown, Value::Number(131.0));
}
#[test]
fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
let run = |stack: usize| {
std::thread::Builder::new()
.stack_size(stack)
.spawn(|| {
let mut wb = Workbook::new();
wb.names.push(gridcraft_model::DefinedName {
name: "CNT".into(),
scope: None,
formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
comment: String::new(),
hidden: false,
});
// F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
[tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
})
.expect("spawn")
.join()
.expect("recursion must not overflow the stack")
};
let small = run(1 << 20);
assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
assert_eq!(run(64 << 20), small);
}
#[test]
fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() {
let run = |stack: usize| {
std::thread::Builder::new()
.stack_size(stack)
.spawn(|| {
// Row-major order calculates A1 first, so each cell below is calculated on demand
// at the deepest call of the one above it.
let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))");
let mut wb = Workbook::new();
let sheet = wb.sheet_mut(0).expect("sheet");
for (at, f) in [
("A1", countdown("A2")),
("A2", countdown("A3")),
("A3", countdown("0")),
("B1", countdown("B2")),
("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()),
] {
sheet.cells.set(c(at), Cell::formula(Formula::new(&f)));
}
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default())
})
.expect("spawn")
.join()
.expect("recursion must not overflow the stack")
};
let small = run(1 << 20);
let num = Value::Error(CellError::Num);
assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]);
assert_eq!(run(64 << 20), small);
}
#[test]
fn names_and_sheets() {
let mut t = T::new();
+30 -3
View File
@@ -160,21 +160,28 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
segs.push((*a, *b));
}
if dash {
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
// long dashed lines multiplies: together they would eagerly allocate megabytes of
// geometry even when nothing of it reaches the canvas, so the total is capped too and
// the line is drawn solid once the budget is gone.
let (on, off) = (width * 4.0, width * 3.0);
let mut out = Vec::new();
let mut budget = 65_536usize;
for (a, b) in segs {
if budget == 0 {
return vec![vec![pts.to_vec()]];
}
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = (dx * dx + dy * dy).sqrt();
if !(len.is_finite() && len > 0.0) {
continue;
}
let mut t = 0.0;
let mut n = 0;
while t < len && n < 10_000 {
while t < len && budget > 0 {
let e = (t + on).min(len);
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
t += on + off;
n += 1;
budget -= 1;
}
}
segs = out;
@@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
}
cv.buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dashed_geometry_stays_within_the_dash_budget() {
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
let prims: Vec<Prim> =
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
assert_eq!(img.len(), 2 * 2 * 4);
// And the geometry itself is bounded even for one huge dashed polyline.
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
}
}
+17 -1636
View File
File diff suppressed because it is too large Load Diff
+452
View File
@@ -0,0 +1,452 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, plot_kind};
use super::lines::{decimate, draw_markers, draw_runs};
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
use super::{
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
VAlign, WHITE, clean, fixed_scale, nice_scale,
};
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Fam {
Col,
ColStack,
Col100,
Line,
LineStack,
Area,
AreaStack,
Area100,
}
/// Stack accumulator: (axis, family) key with positive and negative running sums.
type Stack = ((bool, Fam), Vec<f64>, Vec<f64>);
fn family(k: ChartKind) -> Fam {
use ChartKind::*;
match k {
ColumnStacked | BarStacked => Fam::ColStack,
ColumnStacked100 | BarStacked100 => Fam::Col100,
Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line,
LineStacked => Fam::LineStack,
Area => Fam::Area,
AreaStacked => Fam::AreaStack,
AreaStacked100 => Fam::Area100,
_ => Fam::Col,
}
}
/// Families stacked to 100%: each category's values are shares of its total.
fn is_100(f: Fam) -> bool {
matches!(f, Fam::Col100 | Fam::Area100)
}
fn is_col(f: Fam) -> bool {
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
}
fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
if is_100(fam) {
let key = (sec.get(i).copied().unwrap_or(false), fam);
let p = match totals.iter().position(|(k, _)| *k == key) {
Some(p) => p,
None => {
totals.push((key, vec![0.0; ncat]));
totals.len() - 1
}
};
for (j, v) in s.values.iter().take(ncat).enumerate() {
if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) {
*t += v.abs();
}
}
}
}
// A value's share of its category's total, for the 100% families.
let share = |key: (bool, Fam), j: usize, v: f64| {
let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0);
if t > 0.0 { v / t } else { 0.0 }
};
// Stack accumulators keyed by (axis, family): positive and negative running sums.
let mut stacks: Vec<Stack> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let a = sec.get(i).copied().unwrap_or(false);
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);
let key = (a, fam);
let si = if stacked {
match stacks.iter().position(|(k, _, _)| *k == key) {
Some(p) => Some(p),
None => {
stacks.push((key, vec![0.0; ncat], vec![0.0; ncat]));
Some(stacks.len() - 1)
}
}
} else {
None
};
let mut row = Vec::with_capacity(ncat);
for j in 0..ncat {
let v = s.values.get(j).copied().flatten();
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
let v = if fam == Fam::Col100 { share(key, j, v) } else { v };
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
let base = *acc;
*acc += v;
Some((base, *acc))
}),
(Some((_, pos, _)), _) => {
let v = v.unwrap_or(0.0);
let v = if fam == Fam::Area100 { share(key, j, v) } else { v };
pos.get_mut(j).map(|acc| {
let base = *acc;
*acc = clean(*acc + v);
(base, *acc)
})
}
(None, _) => v.map(|v| (0.0, v)),
};
row.push(span);
}
spans.push(row);
}
spans
}
fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option<(f64, f64)>>]) -> Vec<(Scale, Fmt)> {
// Axis scales.
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
for axis in [false, true] {
let members: Vec<usize> = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect();
if members.is_empty() {
continue;
}
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
let range = value_range(members.iter().flat_map(|i| {
let line = fams.get(*i).is_some_and(|f| is_line(*f));
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
}));
let (lo, hi) = range.unwrap_or((0.0, 0.0));
let (scale, fmt) = if all100 {
(fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))
} else {
let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General");
(nice_scale(lo, hi, force_zero), Fmt::new(code))
};
axes.push((scale, fmt));
if !axis && sec.iter().all(|s| !s) {
break;
}
}
axes
}
fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, f32) {
// Column slots: each clustered series gets one, each stack group shares one.
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
let mut slot_of = vec![0usize; fams.len()];
for (i, f) in fams.iter().enumerate() {
if !is_col(*f) {
continue;
}
let a = sec.get(i).copied().unwrap_or(false);
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
let k = match slot_keys.iter().position(|k| *k == key) {
Some(p) => p,
None => {
slot_keys.push(key);
slot_keys.len() - 1
}
};
if let Some(s) = slot_of.get_mut(i) {
*s = k;
}
}
(slot_of, slot_keys.len().max(1) as f32)
}
pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
let spans = series_spans(data, &fams, &sec, ncat);
let axes = axis_scales(data, &fams, &sec, &spans);
let Some((s1, f1)) = axes.first() else { return };
let y1 = ValAxis { scale: *s1, fmt: f1 };
let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f });
// A forward forecast on a category axis adds empty category slots after the data.
let fwd = data
.series
.iter()
.enumerate()
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
.map(|(_, s)| max_forward(s))
.fold(0.0, f64::max);
let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat));
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
// Outer levels padded to the slots (forecast slots stay in the last group).
let outer: Vec<Vec<String>> =
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } };
let cat_len = if horizontal { plot.h } else { plot.w };
let cw = cat_len / ncat_slots as f32;
let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw };
let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h };
let (slot_of, nslots) = column_slots(&fams, &sec);
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
let mut area_labels = Vec::new();
// Areas first, then columns, then lines on top.
for pass in 0..3 {
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let want = match pass {
0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100),
1 => is_col(fam),
_ => is_line(fam),
};
if !want {
continue;
}
let sc = scale_of(i);
let fmt = Fmt::new(&s.number_format);
let row = match spans.get(i) {
Some(r) => r,
None => continue,
};
match pass {
0 => {
let mut top = Vec::with_capacity(ncat * 2);
let mut base = Vec::with_capacity(ncat);
for (j, sp) in row.iter().enumerate() {
let (b, t) = sp.unwrap_or((0.0, 0.0));
let c = center(j);
top.push([c, vpos(t, &sc)]);
base.push([c, vpos(b, &sc)]);
// Source values (not stack totals), just above the top edge like the line labels.
if labels_on
&& sp.is_some()
&& let Some(v) = s.values.get(j).copied().flatten()
{
area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None)));
}
}
if top.len() == 1 {
// A single point: give it some width.
if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) {
top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]];
base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]];
}
}
let top = decimate(top, MAX_LINE_PTS * 2);
let base = decimate(base, MAX_LINE_PTS * 2);
let mut poly = top;
poly.extend(base.into_iter().rev());
if poly.len() >= 3 {
ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None });
}
}
1 => {
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
for (j, sp) in row.iter().enumerate() {
let Some((b, t)) = sp else { continue };
let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc));
let c = center(j);
let lo = pb.min(pt);
let len = (pb - pt).abs();
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 1.0) * bw;
ctx.rect(lo, y, len, bw, s.color, None);
} else {
let x = c - nslots * bw / 2.0 + k * bw;
ctx.rect(x, lo, bw, len, s.color, None);
}
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
let t = fmt.format(v, None);
let stacked = fam != Fam::Col;
let mid = (pb + pt) / 2.0;
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 0.5) * bw;
if stacked {
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
} else if v >= 0.0 {
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
} else {
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
}
} else {
let x = c - nslots * bw / 2.0 + (k + 0.5) * bw;
if stacked {
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
} else if v >= 0.0 {
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
} else {
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top));
}
}
}
}
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot));
let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);
if k == ChartKind::LineMarkers && cw >= 3.0 {
draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);
}
if labels_on {
for (j, p) in pts.iter().enumerate() {
if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) {
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
}
}
}
for (x, y, t) in area_labels.drain(..) {
ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
// Trendlines on top: x is the category number 1..n.
let mut trend_labels = Vec::new();
for (i, s) in data.series.iter().enumerate() {
if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) {
continue;
}
let sc = scale_of(i);
let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect();
let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min));
let px = |(fx, fy): (f64, f64)| {
if horizontal {
[plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h]
} else {
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
}
};
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
}
draw_trend_labels(ctx, plot, &trend_labels);
}
// ---------------------------------------------------------------- scatter & bubble
pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let pts_of = |s: &SeriesData| -> Vec<Option<(f64, f64)>> {
s.values
.iter()
.enumerate()
.map(|(j, v)| {
let x = match &s.x {
Some(xs) => xs.get(j).copied().flatten(),
None => Some((j + 1) as f64),
};
match (x, v) {
(Some(x), Some(y)) => Some((x, *y)),
_ => None,
}
})
.collect()
};
let all: Vec<Vec<Option<(f64, f64)>>> = data.series.iter().map(pts_of).collect();
let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return };
// Forecasts extend the x axis to the trendline's ends.
for (s, pts) in data.series.iter().zip(&all) {
if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) {
xlo = xlo.min(lo - max_backward(s));
xhi = xhi.max(hi + max_forward(s));
}
}
let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return };
let xs = nice_scale(xlo, xhi, false);
let ys = nice_scale(ylo, yhi, false);
let fx = Fmt::new("General");
let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h];
let bubble = chart.kind == ChartKind::Bubble;
let rmax = plot.w.min(plot.h) * 0.12;
for (s, pts) in data.series.iter().zip(&all) {
let px: Vec<Option<[f32; 2]>> = pts.iter().map(|p| p.map(map)).collect();
if bubble {
let sizes = s.sizes.as_deref().unwrap_or(&[]);
let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b));
let mut bubbles: Vec<([f32; 2], f64)> = px
.iter()
.enumerate()
.filter_map(|(j, p)| {
let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() };
match (p, sz) {
(Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)),
_ => None,
}
})
.take(MAX_MARKERS)
.collect();
bubbles.sort_by(|a, b| b.1.total_cmp(&a.1));
let smax = if sizes.is_empty() { 1.0 } else { smax };
let fill = [s.color[0], s.color[1], s.color[2], 0xC0];
for (p, sz) in bubbles {
let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0);
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) });
}
} else {
let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines;
if lines {
draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot));
}
if !lines || px.len() <= 500 {
draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color);
}
}
if chart.data_labels && pts.len() <= MAX_LABELS {
for (p, raw) in px.iter().zip(pts) {
if let (Some(p), Some((_, y))) = (p, raw) {
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
}
let mut trend_labels = Vec::new();
for (s, pts) in data.series.iter().zip(&all) {
if s.trendlines.is_empty() {
continue;
}
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max));
}
draw_trend_labels(ctx, plot, &trend_labels);
}
+245
View File
@@ -0,0 +1,245 @@
use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
#[derive(Clone, Copy, Debug)]
pub(super) struct Bx {
pub(super) x: f32,
pub(super) y: f32,
pub(super) w: f32,
pub(super) h: f32,
}
impl Bx {
pub(super) fn right(&self) -> f32 {
self.x + self.w
}
pub(super) fn bottom(&self) -> f32 {
self.y + self.h
}
pub(super) fn ok(&self) -> bool {
self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0
}
pub(super) fn cx(&self) -> f32 {
self.x + self.w / 2.0
}
pub(super) fn cy(&self) -> f32 {
self.y + self.h / 2.0
}
}
/// Text colour readable on `fill`.
pub(super) fn on(fill: Rgba) -> Rgba {
let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32;
if l < 150.0 { WHITE } else { DARK_TEXT }
}
pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option<String> {
match &chart.title {
Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()),
None => None,
}
}
pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {
let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;
if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
area.h -= lh;
}
if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h);
if !t.is_empty() {
ctx.out.push(Prim::Text {
x: area.x,
y: area.cy(),
text: t,
size: AXIS_TITLE_SIZE,
color: TEXT,
bold: false,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -PI / 2.0,
});
}
area.x += lh;
area.w -= lh;
}
area
}
// ---------------------------------------------------------------- axes frame
pub(super) enum CatAxis<'a> {
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
/// one entry per category, non-empty where a group starts).
Labels(&'a [String], &'a [Vec<String>]),
Numeric(Scale, &'a Fmt),
}
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
/// non-empty entry (and at the first category).
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
starts.push(n);
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
pub(super) struct ValAxis<'a> {
pub(super) scale: Scale,
pub(super) fmt: &'a Fmt,
}
impl ValAxis<'_> {
fn labels(&self) -> Vec<(f64, String)> {
self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect()
}
}
/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle.
pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx {
let ls = LABEL_SIZE;
let lh = ls * 1.4;
let ylabels = y.labels();
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
if !horizontal {
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
let y2labels = y2.map(ValAxis::labels);
let right = match (&y2labels, &cat) {
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
_ => 4.0,
};
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) => 0.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
if !plot.ok() {
return plot;
}
for (v, t) in &ylabels {
let yy = plot.bottom() - y.scale.frac(*v) * plot.h;
if grid {
ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);
}
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
if let (Some(a), Some(l)) = (y2, &y2labels) {
for (v, t) in l {
let yy = plot.bottom() - a.scale.frac(*v) * plot.h;
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Labels(l, outer) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in l.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
// Outer levels, one row each below the labels: a group's name centred under its
// categories, and a separator at each group boundary down through the rows.
for (i, level) in outer.iter().enumerate() {
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
for (first, end) in level_groups(level, n) {
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if first > 0 {
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
CatAxis::Numeric(s, f) => {
let ticks = s.ticks();
let labels: Vec<String> = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect();
let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let slot = plot.w / ticks.len().max(1) as f32;
let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (v, t) in ticks.iter().zip(&labels).step_by(k) {
let xx = plot.x + s.frac(*v) * plot.w;
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if s.min <= 0.0 && s.max >= 0.0 {
let xx = plot.x + s.frac(0.0) * plot.w;
ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75);
}
}
}
plot
} else {
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
CatAxis::Labels(l, o) => (l.to_vec(), o),
CatAxis::Numeric(..) => (vec![], &[]),
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
// Outer levels stand in columns left of the labels, each as wide as its longest name.
let level_w: Vec<f32> =
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
let levels_w: f32 = level_w.iter().sum();
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
let vw = maxw(ctx, &ylabels);
let slot = plot.w / ylabels.len().max(1) as f32;
let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (i, (v, t)) in ylabels.iter().enumerate() {
let xx = plot.x + y.scale.frac(*v) * plot.w;
if grid {
ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);
}
if i % k == 0 {
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let x0 = plot.x + y.scale.frac(zero) * plot.w;
ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75);
let n = labels.len().max(1);
let ch = plot.h / n as f32;
let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in labels.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
// Outer levels: a group's name centred beside its categories, separators at the
// boundaries running right to the axis.
let mut x1 = plot.x - lw;
for (level, w) in outer.iter().zip(&level_w) {
let x0 = x1 - w;
for (first, end) in level_groups(level, n) {
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, w - 4.0);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle));
}
if first > 0 {
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
}
}
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
x1 = x0;
}
plot
}
}
pub(super) fn value_range(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
it.fold(None, |acc, v| match acc {
None => Some((v, v)),
Some((a, b)) => Some((a.min(v), b.max(v))),
})
}
+508
View File
@@ -0,0 +1,508 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, point_color};
use super::{
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData,
TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- histogram
pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().flatten().copied().collect();
let Some((lo, hi)) = value_range(vals.iter().copied()) else { return };
let n = vals.len();
let span = hi - lo;
let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100);
if !(span.is_finite() && span > 0.0) {
bins = 1;
}
let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 };
// Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value
// equal to an edge belongs to the bin that ends there.
let edges: Vec<f64> = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect();
let mut counts = vec![0usize; bins];
for v in &vals {
let b = edges.partition_point(|e| e < v).min(bins - 1);
if let Some(c) = counts.get_mut(b) {
*c += 1;
}
}
let f = Fmt::new(&s.number_format);
let labels: Vec<String> = (0..bins)
.map(|b| {
let a = lo + b as f64 * width;
let e = edges.get(b).copied().unwrap_or(a);
let open = if b == 0 { '[' } else { '(' };
format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0)))
})
.collect();
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
let ys = nice_scale(0.0, maxc, true);
let fc = Fmt::new("General");
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let cw = plot.w / bins as f32;
for (b, c) in counts.iter().enumerate() {
let top = plot.bottom() - ys.frac(*c as f64) * plot.h;
let x = plot.x + b as f32 * cw;
ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75)));
if chart.data_labels {
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
// ---------------------------------------------------------------- waterfall
pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS);
if ncat == 0 {
return;
}
let mut labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let mut vals: Vec<f64> = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect();
let mut is_total = vec![false; ncat];
let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total"));
if let Some(t) = is_total.last_mut() {
*t = last_total;
}
if !last_total {
labels.push("Total".into());
vals.push(0.0);
is_total.push(true);
ncat += 1;
}
let mut bars = Vec::with_capacity(ncat);
let mut cum = 0.0f64;
for (j, v) in vals.iter().enumerate() {
if is_total.get(j).copied().unwrap_or(false) {
let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum };
bars.push((0.0, total, 0u8));
cum = total;
} else {
let top = clean(cum + v);
bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 }));
cum = top;
}
}
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
let ys = nice_scale(lo, hi, true);
let f = Fmt::new(&s.number_format);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let cw = plot.w / ncat as f32;
let bw = cw / 1.5;
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for (j, (b, t, kind)) in bars.iter().enumerate() {
let color = match kind {
1 => UP,
2 => DOWN,
_ => s.color,
};
let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0;
let (yb, yt) = (vy(*b), vy(*t));
ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None);
if j + 1 < bars.len() && ncat <= 500 {
ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true });
}
if chart.data_labels && ncat <= MAX_LABELS {
let v = if *kind == 0 { *t } else { t - b };
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
}
}
}
// ---------------------------------------------------------------- funnel
pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let n = data.categories.len().max(s.values.len()).min(MAX_CATS);
if n == 0 {
return;
}
let vals: Vec<f64> = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect();
let max = vals.iter().copied().fold(0.0f64, f64::max);
if max.is_nan() || max <= 0.0 {
return;
}
let labels: Vec<String> = (0..n).map(|j| cat_label(data, j)).collect();
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35);
let plot = Bx { x: area.x + lw, w: area.w - lw, ..area };
if !plot.ok() {
return;
}
let slot = plot.h / n as f32;
let bh = slot * 0.85;
let f = Fmt::new(&s.number_format);
let show = slot >= LABEL_SIZE * 0.9;
for (j, v) in vals.iter().enumerate() {
let bw = (*v / max) as f32 * plot.w;
let x = plot.x + (plot.w - bw) / 2.0;
let y = plot.y + j as f32 * slot + (slot - bh) / 2.0;
if bw > 0.0 {
ctx.rect(x, y, bw, bh, s.color, None);
}
if show {
let yc = y + bh / 2.0;
if chart.data_labels {
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, (HAlign::Center, VAlign::Middle));
}
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
}
}
}
// ---------------------------------------------------------------- pie, doughnut, sunburst
pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
let total: f64 = vals.iter().sum();
if !(total.is_finite() && total > 0.0) {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let r = area.w.min(area.h) / 2.0 * 0.92;
if r < 2.0 {
return;
}
let ri = r * inner;
let f = Fmt::new(&s.number_format);
let mut a = 0.0f32;
let mut labels = Vec::new();
for (j, v) in vals.iter().enumerate() {
let sweep = (*v / total) as f32 * TAU;
if sweep > 1e-4 {
let fill = point_color(data, j);
ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) });
if (chart.data_labels || names) && vals.len() <= MAX_LABELS {
let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) };
let mid = a + sweep / 2.0;
let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 };
let room = sweep * rr;
if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE {
labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill)));
}
}
}
a += sweep;
}
for (x, y, t, c) in labels {
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
}
}
// ---------------------------------------------------------------- treemap
fn worst(row: &[f64], side: f64) -> f64 {
let s: f64 = row.iter().sum();
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
}
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
let s2 = s * s;
let w2 = side * side;
(w2 * mx / s2).max(s2 / (w2 * mn))
}
/// Squarified treemap layout of (index, area) items, sorted descending, into `r`.
fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let total: f64 = items.iter().map(|i| i.1).sum();
let area = (r.w as f64) * (r.h as f64);
if !(total > 0.0 && area > 0.0) {
return vec![];
}
let scale = area / total;
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
if s <= 0.0 {
return;
}
if rem.w >= rem.h {
let cw = (s / rem.h.max(1e-6) as f64) as f32;
let mut y = rem.y;
for (j, a) in row {
let h = (*a / cw.max(1e-6) as f64) as f32;
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
y += h;
}
rem.x += cw;
rem.w = (rem.w - cw).max(0.0);
} else {
let rh = (s / rem.w.max(1e-6) as f64) as f32;
let mut x = rem.x;
for (j, a) in row {
let w = (*a / rh.max(1e-6) as f64) as f32;
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
x += w;
}
rem.y += rh;
rem.h = (rem.h - rh).max(0.0);
}
};
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
let mut with = cur.clone();
with.push(a);
if row.is_empty() || worst(&with, side) <= worst(&cur, side) {
row.push((j, a));
i += 1;
} else {
layout(&row, &mut rem, &mut out);
row.clear();
}
}
if !row.is_empty() {
layout(&row, &mut rem, &mut out);
}
out
}
pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let mut items: Vec<(usize, f64)> =
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
items.sort_by(|a, b| b.1.total_cmp(&a.1));
let f = Fmt::new(&s.number_format);
for (j, b) in squarify(&items, area) {
let fill = point_color(data, j);
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
}
}
}
}
// ---------------------------------------------------------------- radar
pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000);
if ncat == 0 {
return;
}
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2);
let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0);
if r < 5.0 {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0));
let sc = nice_scale(lo, hi, true);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let ang = |j: usize| j as f32 / ncat as f32 * TAU;
let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()];
let ticks = sc.ticks();
let spacing = r / ticks.len().saturating_sub(1).max(1) as f32;
let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1);
for (ti, t) in ticks.into_iter().enumerate() {
let rr = sc.frac(t) * r;
if rr > 0.5 && chart.gridlines {
let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect();
if ncat < 3 {
ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect();
} else if let Some(p) = ring.first().copied() {
ring.push(p);
}
ctx.line(ring, GRID, 0.75);
}
if ti % every == 0 {
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, (HAlign::Right, VAlign::Middle));
}
}
let step = ncat.div_ceil(72).max(1);
for j in (0..ncat).step_by(step) {
let a = ang(j);
ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75);
let p = pt(a, r + 6.0);
let (sa, ca) = (a.sin(), a.cos());
let align = if sa > 0.1 {
HAlign::Left
} else if sa < -0.1 {
HAlign::Right
} else {
HAlign::Center
};
let valign = if ca > 0.1 {
VAlign::Bottom
} else if ca < -0.1 {
VAlign::Top
} else {
VAlign::Middle
};
let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0));
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, (align, valign));
}
for s in &data.series {
let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect();
if let Some(p) = pts.first().copied() {
pts.push(p);
}
ctx.line(pts, s.color, 2.0);
}
}
// ---------------------------------------------------------------- box & whisker
fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
// Clamp exclusive ranks for short samples so one or two points remain drawable.
let pos = match method {
QuartileMethod::Inclusive => q * (n - 1) as f64,
QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64),
};
let i = pos.floor() as usize;
let frac = pos - i as f64;
let a = sorted.get(i).copied().unwrap_or(0.0);
let b = sorted.get(i + 1).copied().unwrap_or(a);
a + (b - a) * frac
}
pub(super) fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let labels: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let n = data.series.len().max(1);
let cw = plot.w / n as f32;
let bw = (cw * 0.4).min(80.0);
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for (i, s) in data.series.iter().enumerate() {
let mut v: Vec<f64> = s.values.iter().flatten().copied().collect();
if v.is_empty() {
continue;
}
v.sort_by(f64::total_cmp);
let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default();
let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method));
let iqr = q3 - q1;
let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1);
let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3);
let mean = v.iter().sum::<f64>() / v.len() as f64;
let c = plot.x + (i as f32 + 0.5) * cw;
ctx.vline(c, vy(whi), vy(q3), s.color, 1.25);
ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25);
ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25);
ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25);
let (yt, yb) = (vy(q3), vy(q1));
ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None);
ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5);
let (mx, my, d) = (c, vy(mean), 3.0);
ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25);
ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25);
for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) {
ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) });
}
}
}
// ---------------------------------------------------------------- stock
pub(super) fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let ser = |k: usize| data.series.get(k);
let (open, high, low, close) = match data.series.len() {
0 => return,
1 => (None, None, None, ser(0)),
2 => (None, ser(0), ser(1), None),
3 => (None, ser(0), ser(1), ser(2)),
_ => (ser(0), ser(1), ser(2), ser(3)),
};
let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten());
let cw = plot.w / ncat as f32;
let tick = (cw * 0.25).max(1.0);
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for j in 0..ncat {
let c = plot.x + (j as f32 + 0.5) * cw;
if let (Some(h), Some(l)) = (get(high, j), get(low, j)) {
ctx.vline(c, vy(h), vy(l), STOCK, 1.25);
}
match (get(open, j), get(close, j)) {
(Some(o), Some(cl)) => {
let (yo, yc) = (vy(o), vy(cl));
let bw = (cw * 0.5).max(1.0);
let fill = if cl >= o { WHITE } else { STOCK };
ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0)));
}
(None, Some(cl)) => {
if high.is_some() {
ctx.hline(c, c + tick, vy(cl), STOCK, 1.25);
} else {
ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None });
}
}
_ => {}
}
}
}
#[cfg(test)]
mod quartile_tests {
use super::*;
#[test]
fn box_whisker_default_quartiles_are_exclusive() {
let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75);
assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5);
assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25);
}
#[test]
fn quartile_methods_and_short_samples() {
for (values, exclusive, inclusive) in [
(vec![1., 2., 3.], (1., 3.), (1.5, 2.5)),
(vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)),
(vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)),
(vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)),
(vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)),
(vec![1., 2.], (1., 2.), (1.25, 1.75)),
(vec![1.], (1., 1.), (1., 1.)),
(vec![], (0., 0.), (0., 0.)),
] {
for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] {
assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected);
}
}
}
}
+165
View File
@@ -0,0 +1,165 @@
use super::frame::Bx;
use super::trend::supports_trendline;
use super::{
Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP,
VAlign, auto_name, series_color,
};
use gridcraft_model::LegendPos;
/// How a legend entry's swatch is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Swatch {
Box,
Line,
LineMarker,
Marker,
}
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
}
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba {
match (j, data.series.first()) {
(0, Some(s)) => s.color,
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
}
}
fn first_len(data: &ChartData) -> usize {
data.series.first().map(|s| s.values.len()).unwrap_or(0)
}
pub(super) fn cat_label(data: &ChartData, j: usize) -> String {
data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string())
}
/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden.
pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> {
if chart.legend == LegendPos::None {
return vec![];
}
match chart.kind {
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
let n = first_len(data).min(MAX_CATS);
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
}
ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![],
ChartKind::Waterfall => {
if data.series.is_empty() {
return vec![];
}
let total = data.series.first().map(|s| s.color).unwrap_or(TEXT);
vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)]
}
_ => {
let series = data.series.iter().take(MAX_SERIES);
let mut out: Vec<(String, Rgba, Swatch)> = series
.clone()
.map(|s| {
let sw = match plot_kind(chart, s) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
_ => Swatch::Box,
};
(s.name.clone(), s.color, sw)
})
.collect();
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
}
}
out
}
}
}
// ---------------------------------------------------------------- legend
fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
match sw {
Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
Swatch::LineMarker => {
ctx.hline(x, x + 2.0 * s, yc, color, 2.0);
ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None });
}
Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }),
}
}
pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {
let size = LABEL_SIZE;
let row_h = size * 1.6;
let s = size * 0.8;
let gap = 4.0;
let spacing = size * 1.3;
let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);
match pos {
LegendPos::None => area,
LegendPos::Top | LegendPos::Bottom => {
let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);
let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];
let mut cur = 0.0f32;
for (i, e) in entries.iter().enumerate() {
let iw = item_w(ctx, e).min(area.w);
let need = if cur > 0.0 { spacing + iw } else { iw };
if cur > 0.0 && cur + need > area.w {
if rows.len() >= max_rows {
break;
}
rows.push(vec![]);
cur = 0.0;
}
cur += if cur > 0.0 { spacing + iw } else { iw };
if let Some(r) = rows.last_mut() {
r.push((i, iw));
}
}
let lh = rows.len() as f32 * row_h;
let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };
for (ri, row) in rows.iter().enumerate() {
let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;
let mut x = area.x + (area.w - total).max(0.0) / 2.0;
let yc = y0 + ri as f32 * row_h + row_h / 2.0;
for &(i, iw) in row {
if let Some(e) = entries.get(i) {
draw_swatch(ctx, e.2, x, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
x += iw + spacing;
}
}
let used = lh + 4.0;
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
}
LegendPos::Left | LegendPos::Right => {
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
let max_rows = ((area.h / row_h).floor() as usize).max(1);
let n = entries.len().min(max_rows);
let lh = n as f32 * row_h;
let y0 = area.y + (area.h - lh).max(0.0) / 2.0;
let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };
for (i, e) in entries.iter().take(n).enumerate() {
let yc = y0 + i as f32 * row_h + row_h / 2.0;
draw_swatch(ctx, e.2, x0, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
let used = colw + 8.0;
if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
}
}
}
+158
View File
@@ -0,0 +1,158 @@
use super::frame::Bx;
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
use std::collections::HashSet;
// ---------------------------------------------------------------- helpers
/// Reduces a long polyline to first/min/max/last per pixel column.
pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
if pts.len() <= max {
return pts;
}
let mut out = Vec::with_capacity(max * 2);
let mut i = 0;
while let Some(p) = pts.get(i) {
let col = p[0].floor();
let (start, mut mn, mut mx) = (i, i, i);
let mut k = i + 1;
while let Some(q) = pts.get(k) {
if q[0].floor() != col {
break;
}
if pts.get(mn).is_some_and(|m| q[1] < m[1]) {
mn = k;
}
if pts.get(mx).is_some_and(|m| q[1] > m[1]) {
mx = k;
}
k += 1;
}
let mut idx = [start, mn, mx, k - 1];
idx.sort_unstable();
let mut last = usize::MAX;
for ix in idx {
if ix != last
&& let Some(q) = pts.get(ix)
{
out.push(*q);
last = ix;
}
}
i = k;
}
if out.len() > max * 4 {
let step = out.len() / (max * 2) + 1;
let last = out.last().copied();
let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect();
if let Some(l) = last
&& s.last() != Some(&l)
{
s.push(l);
}
return s;
}
out
}
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
let mut run: Vec<[f32; 2]> = Vec::new();
for p in pts.iter().chain(std::iter::once(&None)) {
match p {
Some(p) => run.push(*p),
None => {
if run.len() >= 2 {
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
let r = match smooth {
Some(plot) => smooth_run(r, plot),
None => r,
};
ctx.line(r, color, width);
} else {
run.clear();
}
}
}
}
}
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
/// duplicated so the curve passes through the first and last points, and the curve is clamped
/// to the `plot` box (a spline overshoots its data near sharp turns).
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
let n = pts.len();
if n < 3 {
return pts;
}
let cap = MAX_LINE_PTS * 2;
let steps = (cap / n).clamp(1, 100);
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
let at = |i: isize| -> [f32; 2] {
let j = i.clamp(0, n as isize - 1) as usize;
pts.get(j).copied().unwrap_or([0.0, 0.0])
};
let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right()));
let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom()));
let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)];
for i in 0..n.saturating_sub(1) {
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
for s in 0..steps {
let t = s as f32 / steps as f32;
out.push(clip(catmull(p0, p1, p2, p3, t)));
}
}
out.push(clip(at(n as isize - 1)));
out
}
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
let t2 = t * t;
let t3 = t2 * t;
let mut o = [0.0f32; 2];
for k in 0..2 {
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
}
o
}
pub(super) fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
let mut seen = HashSet::new();
for p in pts {
if seen.len() >= MAX_MARKERS {
break;
}
if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) {
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) });
}
}
}
#[cfg(test)]
mod smooth_tests {
use super::*;
#[test]
fn long_runs_are_smoothed_uniformly_within_the_cap() {
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
let out = smooth_run(pts, plot);
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
assert_eq!(out.len(), 1499 * 2 + 1);
assert_eq!(out.last().copied(), Some([2998.0, 80.0]));
}
#[test]
fn spline_overshoot_is_clamped_to_the_plot() {
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
// A step right at the plot edges: Catmull-Rom overshoots past both.
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
let out = smooth_run(pts, plot);
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
}
+170
View File
@@ -0,0 +1,170 @@
use super::frame::{Bx, value_range};
use super::lines::decimate;
use super::{
ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign,
fit_trendline, moving_average,
};
// ---------------------------------------------------------------- trendlines
/// Samples along a regression trendline.
const TREND_STEPS: usize = 200;
/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column,
/// bar, line, area, scatter and bubble charts).
pub fn supports_trendline(k: ChartKind) -> bool {
use ChartKind::*;
matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble)
}
/// A forecast distance as drawn: finite, non-negative and bounded.
fn forecast(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
}
/// Most empty category slots a forward forecast may add after `ncat` categories: four times the
/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every
/// column to nothing.
pub(super) fn max_forward_slots(ncat: usize) -> f64 {
(ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64
}
/// Longest forward forecast among `s`'s regression trendlines.
pub(super) fn max_forward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
}
/// Longest backward forecast among `s`'s regression trendlines.
pub(super) fn max_backward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
}
type ClippedSegment = ((f64, f64), (f64, f64), bool, bool);
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
/// end were cut.
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<ClippedSegment> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let r = q / p;
if p < 0.0 {
if r > t1 {
return None;
}
t0 = t0.max(r);
} else {
if r < t0 {
return None;
}
t1 = t1.min(r);
}
}
}
Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0))
}
/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit
/// square), returning the visible runs.
fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec<Vec<(f64, f64)>> {
const LIM: f64 = 1e9;
let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite();
let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM));
let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
let mut run: Vec<(f64, f64)> = Vec::new();
for w in pts.windows(2) {
let seg = match w {
[Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)),
_ => None,
};
match seg {
Some((p, q, cut_start, cut_end)) => {
if cut_start || run.is_empty() {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
run.push(p);
}
run.push(q);
if cut_end {
runs.push(std::mem::take(&mut run));
}
}
None => {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
}
}
}
if run.len() >= 2 {
runs.push(run);
}
runs
}
/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to
/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels.
/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing.
pub(super) fn draw_trendlines(
ctx: &mut Ctx,
s: &SeriesData,
pts: &[(f64, f64)],
frac: &dyn Fn(f64, f64) -> (f64, f64),
px: &dyn Fn((f64, f64)) -> [f32; 2],
labels: &mut Vec<String>,
window: (f64, f64),
) {
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
let spec = &t.spec;
let samples: Vec<Option<(f64, f64)>> = match spec.kind {
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
_ => {
let Some(fit) = fit_trendline(spec, pts) else { continue };
// Sample only the part of the forecast that is on the plot.
let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1));
if spec.display_equation {
labels.push(fit.equation());
}
if spec.display_r_squared {
labels.push(fit.r_squared());
}
(0..=TREND_STEPS)
.map(|i| {
let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64;
fit.eval(x).map(|y| frac(x, y))
})
.collect()
}
};
let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0);
let dash = spec.dash != Some(LineDash::Solid);
for run in clip_unit(&samples) {
let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS);
if line.len() >= 2 {
ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash });
}
}
}
}
/// Trendline equation / R² labels, stacked in the plot's top-right corner.
pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
let lh = LABEL_SIZE * 1.3;
for (i, t) in labels.iter().enumerate() {
let y = plot.y + 4.0 + i as f32 * lh;
if y + lh > plot.bottom() {
break;
}
let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0);
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top));
}
}
+43 -3
View File
@@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
const ALL_KINDS: [ChartKind; 25] = [
const ALL_KINDS: [ChartKind; 26] = [
ChartKind::ColumnClustered,
ChartKind::ColumnStacked,
ChartKind::ColumnStacked100,
@@ -19,6 +19,7 @@ const ALL_KINDS: [ChartKind; 25] = [
ChartKind::Doughnut,
ChartKind::Area,
ChartKind::AreaStacked,
ChartKind::AreaStacked100,
ChartKind::Scatter,
ChartKind::ScatterLines,
ChartKind::Bubble,
@@ -76,7 +77,7 @@ fn sample(kind: ChartKind) -> ChartData {
ChartData {
category_levels: vec![],
palette: vec![],
categories: cats.iter().map(|s| s.to_string()).collect(),
categories: cats.iter().map(ToString::to_string).collect(),
series: vec![
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
b,
@@ -1073,7 +1074,7 @@ fn pie_points_follow_workbook_theme() {
#[test]
fn area_charts_draw_source_value_labels_only_when_enabled() {
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] {
let d = sample(kind);
let mut c = chart(kind);
c.data_labels = false;
@@ -1097,6 +1098,45 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
}
}
#[test]
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100),
series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100),
],
};
let mut c = chart(ChartKind::AreaStacked100);
c.data_labels = false;
let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let t = texts(&p);
assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}");
assert!(!t.contains(&"40"), "{t:?}");
let polys: Vec<&Vec<[f32; 2]>> = p
.iter()
.filter_map(|p| match p {
Prim::Polygon { pts, .. } => Some(pts),
_ => None,
})
.collect();
let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") };
// Each polygon is its top edge left to right, then its base right to left.
let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN);
let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN);
let (y0, y100) = (base(north, 0), top(south, 0));
assert!(y0 > y100, "{y0} {y100}");
let frac = |y: f32| (y0 - y) / (y0 - y100);
for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] {
assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j)));
assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j)));
}
// A category whose total is zero stays on the baseline.
assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2)));
}
#[test]
fn two_level_categories_resolve_and_draw() {
let years = ["2023", "", "", "2024", "", ""];
+40 -21
View File
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
chars.next();
letters += 1;
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
while chars.peek().is_some_and(char::is_ascii_digit) {
chars.next();
}
}
@@ -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]
@@ -350,6 +351,21 @@ mod tests {
assert_eq!(split_sheet("A1"), (None, "A1"));
}
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
/// reference.
#[test]
fn split_sheet_leaves_device_names_inert_in_the_body() {
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
let (sheet, rest) = split_sheet(s);
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
let _ = sheet;
}
// A plain single-letter sheet is still split, not mistaken for a body.
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
}
#[test]
fn letters_roundtrip() {
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
@@ -374,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]
@@ -443,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(())
}
}
+22 -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")]
{
@@ -174,6 +172,9 @@ pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if frac_ms >= 86_400_000 {
frac_ms -= 86_400_000;
day += 1;
if day > max_serial(sys) {
return None;
}
}
let (y, m, d, weekday) = match sys {
DateSystem::D1900 => {
@@ -221,6 +222,14 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[test]
fn serial_rounding_into_next_day_past_max_is_none() {
for sys in [DateSystem::D1900, DateSystem::D1904] {
let serial = max_serial(sys) as f64 + 0.9999999995;
assert_eq!(datetime_from_serial(sys, serial), None);
}
}
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
@@ -312,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;
@@ -334,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);
@@ -347,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]
@@ -360,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(())
}
}
+23 -3
View File
@@ -371,10 +371,16 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
if let Some((d, f)) = parse_date(t, sys) {
return Some((d, Some(f)));
}
// date + time: split at the last space(s) before a time-looking tail.
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
// instead of reparsing every prefix for every space (one long text field quadratic).
for (i, _) in t.match_indices(' ') {
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
let Some(b) = t.get(i + 1..) else { continue };
let bytes = b.as_bytes();
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
continue;
}
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
return Some((d + tf, Some("m/d/yyyy h:mm")));
}
}
@@ -400,6 +406,20 @@ mod tests {
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn overlong_time_fields_stay_text() {
// #2168: all-digit field lengths are gated before allocation/derivation,
// so hostile strings never spin the time-derivation machinery.
let big = "9".repeat(500);
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
// Within the gate, times still work.
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
+5 -4
View File
@@ -245,7 +245,7 @@ impl Value {
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
Value::Text(t) => Ok(t.to_string()),
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
}
}
/// Coercion to a boolean as `IF` does it.
@@ -264,7 +264,7 @@ impl Value {
}
}
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
}
}
/// Display text with General formatting (for logs, CSV and tests).
@@ -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"),
+96 -78
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use gridcraft_core::{CellRef, RangeRef, Value, compare};
use gridcraft_model::*;
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
/// The conditional look of one cell.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -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
@@ -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());
@@ -165,7 +167,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
@@ -205,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(),
@@ -296,7 +297,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
{
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
}
Ok(d.wb.active_sheet)
Ok(d.wb.active_sheet())
}
pub(crate) use gridcraft_core::split_sheet;
@@ -305,7 +306,7 @@ pub(crate) use gridcraft_core::split_sheet;
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
cell_param_on(s, p, key, s.doc()?.wb.active_sheet())
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
@@ -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.";
@@ -593,7 +623,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
impl Ctx<'_> {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
self.wb.active_sheet()
}
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
+2 -2
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet};
use serde_json::{Value as Json, json};
use super::whatif::parse_range;
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param};
const CMD: &str = "data.analysisToolPak";
/// Most input cells a tool reads.
@@ -1182,7 +1182,7 @@ mod tests {
fn v(s: &Session, a: &str) -> Value {
let d = s.doc().unwrap();
let (sh, body) = crate::cmd::split_sheet(a);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
}
fn n(s: &Session, a: &str) -> f64 {
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
use super::sort::sort_key;
use super::{
AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param,
current_region, data_target, edit, json,
};
// ---------------------------------------------------------------- filter
pub(super) fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.filter")?;
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start };
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
if let Some(name) = in_table {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) {
t.filter_button = !t.filter_button;
if !t.filter_button {
t.filter.clear();
}
}
Ok(Json::Null)
});
}
if has {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
sh.autofilter = None;
for r in filtered {
if let Some(info) = sh.rows.get_mut(&r) {
info.hidden = false;
}
}
cx.structural = true;
Ok(json!({"filter": false}))
});
}
install_filter(s, p)
}
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, mut r) = data_target(s, p, "data.filter")?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let below = current_region(sh, r.start).end.row;
if below > r.end.row {
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
Ok(json!({"filter": true, "range": r.a1()}))
})
}
pub(super) fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let af = match &sh.autofilter {
Some(a) => a.clone(),
None => {
install_filter(s, &json!({}))?;
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
}
};
let col = match col_param(p.get("column"), af.range.start.col) {
Some(c) => c,
// A number too large to be a column is outside the filter range, not a missing parameter.
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
None => return Err(bad("data.filterBy", "missing `column`")),
};
if col < af.range.start.col || col > af.range.end.col {
return Err(bad("data.filterBy", "column outside the filter range"));
}
let off = col - af.range.start.col;
let crit = if bool_param(p, "clear").unwrap_or(false) {
None
} else if let Some(vals) = p.get("values").and_then(Json::as_array) {
Some(FilterCriterion::Values {
values: vals.iter().map(crate::cmd::edit::json_to_input).collect(),
blanks: bool_param(p, "blanks").unwrap_or(false),
})
} else if let Some(c) = p.get("custom") {
let a =
(c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default());
let b =
c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(crate::cmd::edit::json_to_input).unwrap_or_default()));
Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) })
} else if let Some(t) = p.get("top") {
Some(FilterCriterion::Top10 {
bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false),
percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false),
count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32,
})
} else {
bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage)
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
a.criteria.retain(|(o, _)| *o != off);
if let Some(c) = crit.clone() {
a.criteria.push((off, c));
}
}
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})?;
let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0);
Ok(json!({"hiddenRows": hidden}))
}
/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)?
fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
let num = gridcraft_core::parse::parse_number_text(crit);
let cmp = match (v.as_f64(), num) {
(Some(a), Some(b)) => a.partial_cmp(&b),
// A numeric ordering comparison only matches numbers; blanks and text never do.
(None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None,
_ => Some(gridcraft_core::compare_text(text, crit)),
};
let wild = || crate::cmd::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase());
match op {
"=" => {
if crit.contains(['*', '?']) {
wild()
} else {
cmp == Some(Ordering::Equal)
}
}
"<>" => {
if crit.contains(['*', '?']) {
!wild()
} else {
cmp != Some(Ordering::Equal)
}
}
">" => cmp == Some(Ordering::Greater),
"<" => cmp == Some(Ordering::Less),
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
"beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()),
"endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()),
"contains" => text.to_lowercase().contains(&crit.to_lowercase()),
"notContains" => !text.to_lowercase().contains(&crit.to_lowercase()),
_ => true,
}
}
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
return;
}
let Some(af) = sh.autofilter.as_ref() else { return };
let r = af.range;
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
return;
}
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
if let Some(af) = sh.autofilter.as_mut() {
af.range = grown;
}
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
let Some(af) = sh.autofilter.clone() else { return };
let r = af.range;
let mut hide = Vec::new();
// Precompute per-criterion top10 threshold and average once, not per row.
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
for (i, (off, c)) in af.criteria.iter().enumerate() {
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
let col = r.start.col + off;
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
let thr = match c {
FilterCriterion::Top10 { bottom, percent, count } => {
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
let k = k.clamp(1, nums.len().max(1));
nums.get(k - 1).copied()
}
_ => None,
};
col_stats.insert(i, (thr, avg));
}
}
for row in r.start.row + 1..=r.end.row {
let mut visible = true;
for (i, (off, crit)) in af.criteria.iter().enumerate() {
let c = CellRef::new(row, r.start.col + off);
let v = sh.value(c);
let text = crate::display::cell_text(wb, sh, c);
let ok = match crit {
FilterCriterion::Values { values, blanks } => {
if filter_blank(&v) {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
}
}
FilterCriterion::Custom { a, b, and } => {
let x = custom_ok(&v, &text, &a.0, &a.1);
match b {
Some(b) => {
let y = custom_ok(&v, &text, &b.0, &b.1);
if *and { x && y } else { x || y }
}
None => x,
}
}
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((thr, _))) => {
let thr = thr.unwrap_or(n);
if *bottom { n <= thr } else { n >= thr }
}
_ => false,
},
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
(Some(n), Some((_, avg))) => {
if *above {
n > *avg
} else {
n < *avg
}
}
_ => false,
},
FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col,
FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col,
// Icons are not evaluated here: the row keeps its current visibility.
FilterCriterion::Icon { .. } => !sh.is_row_hidden(row),
};
if !ok {
visible = false;
break;
}
}
hide.push((row, !visible));
}
let Some(shm) = wb.sheet_mut(sheet) else { return };
for (row, h) in hide {
if h {
shm.rows.entry(row).or_default().hidden = true;
} else if let Some(e) = shm.rows.get_mut(&row) {
e.hidden = false;
}
}
}
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
a.criteria.clear();
}
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
Ok(Json::Null)
})
}
/// Distinct display values of a filter column (for the dropdown list).
pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> {
let Some(sh) = wb.sheet(sheet) else { return vec![] };
let Some(af) = &sh.autofilter else { return vec![] };
let off = col.saturating_sub(af.range.start.col);
let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c);
let mut seen = std::collections::BTreeMap::new();
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
let c = CellRef::new(row, col);
let v = sh.value(c);
let blank = filter_blank(&v);
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
let on = match selected {
Some(FilterCriterion::Values { values, blanks }) => {
if blank {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
}
}
_ => true,
};
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
seen.entry((key, t)).or_insert(on);
}
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
+270
View File
@@ -0,0 +1,270 @@
use super::{
CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines,
str_param, target_sheet,
};
// ---------------------------------------------------------------- outline
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
return Ok((true, r.start.row, r.end.row));
}
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
return Ok((false, r.start.col, r.end.col));
}
let r = s.doc()?.selection.current();
match str_param(p, "by") {
Some("rows") => Ok((true, r.start.row, r.end.row)),
Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)),
_ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)),
_ => Ok((true, r.start.row, r.end.row)),
}
}
pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
let e = map.entry(i).or_default();
e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) };
if !on && e.outline == 0 {
e.hidden = false;
}
}
// A summary line whose group is gone has nothing left to collapse.
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
if !on
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
{
e.collapsed = false;
}
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or
/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let all = outline::groups_with(map, before);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0);
hits.into_iter().filter(|g| g.level == deepest).collect()
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
}
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if !g.has_summary() {
continue;
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
cx.structural = true;
Ok(Json::Null)
})
}
/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as
/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the
/// group expanded and leave it invisible (#1714).
fn set_hidden(e: &mut LineInfo, hidden: bool) {
e.hidden = hidden;
if !hidden && e.size == Some(0.0) {
e.size = None;
}
}
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8);
let (rows, cols) = (level("rows"), level("cols"));
if rows.is_none() && cols.is_none() {
return Err(EngineError::BadParams {
cmd: "data.showLevels".into(),
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
let Some(n) = n else { continue };
let groups = outline::groups_with(map, before);
for e in map.values_mut().filter(|e| e.outline > 0) {
set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups.into_iter().filter(|g| g.has_summary()) {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
cx.structural = true;
Ok(Json::Null)
})
}
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
/// group whose detail is all hidden, or the outermost when none is.
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
let mut marked: Vec<outline::OutlineGroup> =
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
pub(super) fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
let Some(on) = bool_param(p, key) else { continue };
let was_before = outline::summary_before(sh, rows);
if rows {
sh.summary_below = on;
} else {
sh.summary_right = on;
}
if was_before != on {
continue;
}
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
}
pub(super) fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
let func = str_param(p, "function").unwrap_or("sum");
let code = match func {
"count" => 3,
"average" => 1,
"max" => 4,
"min" => 5,
"product" => 6,
_ => 9,
};
let cols: Vec<u32> = p
.get("columns")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
.unwrap_or_else(|| vec![r.end.col]);
let label = match func {
"count" => "Count",
"average" => "Average",
"max" => "Max",
"min" => "Min",
"product" => "Product",
_ => "Total",
};
// Group boundaries (header in the first row).
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let mut groups: Vec<(u32, u32, String)> = Vec::new();
let first = r.start.row + u32::from(header);
let mut start = first;
for row in first..=r.end.row + 1 {
let cur = sh.value(CellRef::new(row, group_col)).display();
let prev = sh.value(CellRef::new(start, group_col)).display();
if row > r.end.row || cur != prev {
groups.push((start, row - 1, prev));
start = row;
}
}
// The totals go below each group, so the outline puts summary rows below their detail (the
// Subtotal dialog's "Summary below data"), in the same undo step.
if !sh.summary_below {
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
}
// Insert a total row after each group, bottom-up so indices stay valid.
let mut inserted = 0;
for (g0, g1, name) in groups.iter().rev() {
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
let row = g1 + 1;
let lbl = CellRef::new(row, group_col).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
s.execute(
"home.bold",
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
)?;
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
inserted += 1;
}
let last = r.end.row + inserted + 1;
let lbl = CellRef::new(last, group_col).a1();
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
Ok(json!({"groups": inserted}))
}
+325
View File
@@ -0,0 +1,325 @@
use super::{
Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet,
Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet,
};
#[derive(Clone)]
struct SortKey {
col: u32,
desc: bool,
by: String,
list: Option<Vec<String>>,
}
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if let Some(t) = str_param(p, "range")
&& let (Some(name), _) = split_sheet(t)
&& d.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
}
pub(super) fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
let (sheet, r) = sort_target(s, p)?;
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = guess_header(&d.wb, sh, r);
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
}
pub(super) fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = sort_target(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let by_cols = str_param(p, "orientation") == Some("columns");
let case = bool_param(p, "matchCase").unwrap_or(false);
let mut keys: Vec<SortKey> = Vec::new();
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
let col = if by_cols {
let row = match k.get("row").and_then(Json::as_u64) {
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
Some(n) => {
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
}
None => None,
};
row.or_else(|| col_param(k.get("column"), r.start.row))
} else {
col_param(k.get("column"), r.start.col)
};
let Some(col) = col else { continue };
keys.push(SortKey {
col,
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
});
}
if keys.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
return ok();
}
if keys.len() > 64 {
return Err(bad("data.sort", "too many sort levels (64 max)"));
}
do_sort(s, sheet, r, header, keys, by_cols, case)
}
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
crate::cmd::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
let body = if by_cols {
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
} else if header && r.height() > 1 {
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
} else {
r
};
if body.count() > 20_000_000 {
return Err(bad("data.sort", "range too large"));
}
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into()));
}
let n = if by_cols { body.width() } else { body.height() };
let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) };
let theme = cx.wb.theme.clone();
let styles = &cx.wb.styles;
let key_val = |i: u32, k: &SortKey| -> (Value, u32) {
let c = line(i, k.col);
let v = sh.value(c);
let color = match k.by.as_str() {
"cellColor" => {
styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX)
}
"fontColor" => {
styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX)
}
_ => 0,
};
(v, color)
};
let mut order: Vec<u32> = (0..n).collect();
let keyed: Vec<Vec<(Value, u32)>> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect();
order.sort_by(|&a, &b| {
for (ki, k) in keys.iter().enumerate() {
let (va, ca) = &keyed[a as usize][ki];
let (vb, cb) = &keyed[b as usize][ki];
let o = if k.by != "values" {
ca.cmp(cb)
} else if let Some(list) = &k.list {
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
pos(va).cmp(&pos(vb))
} else if case && va.is_text() && vb.is_text() {
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
} else {
sort_compare(va, vb)
};
// Blanks always sort last, whatever the order.
let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o };
if o != Ordering::Equal {
return o;
}
}
Ordering::Equal
});
if order.iter().enumerate().all(|(i, &o)| i as u32 == o) {
return Ok(Json::Null);
}
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
// describe — the way cell pictures and row heights do. Collect them before inserting,
// so a swap can't resurrect one from a destination that is also a source.
let side: Vec<(CellRef, Comment)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
.collect();
let links: Vec<(CellRef, Hyperlink)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
.collect();
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
let mut pictures = Vec::new();
for (dst_i, &src_i) in order.iter().enumerate() {
for &k in &lanes {
let src = line(src_i, k);
let dst = line(dst_i as u32, k);
let mut cell = sh.cell(src).cloned();
if let Some(c) = cell.as_mut()
&& let Some(f) = &c.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64);
c.formula = Some(Arc::new(Formula::from_expr(shifted)));
}
if let Some(picture) = sh.cell_pictures.get(&src) {
pictures.push((dst, picture.clone()));
}
moved.push((dst, cell));
}
}
// Row heights travel with rows.
let heights: Vec<Option<LineInfo>> =
if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() };
// Floating objects anchored inside the sorted body travel with their lines, unless they
// are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering
// is the inverse of `order`: `dst_of[src] = dst`.
let dst_of: Vec<u32> = {
let mut v = vec![0u32; order.len()];
for (dst_i, &src_i) in order.iter().enumerate() {
if let Some(slot) = v.get_mut(src_i as usize) {
*slot = dst_i as u32;
}
}
v
};
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
// can be re-fitted to the lines it spans after the sort.
let objs: Vec<Anchor> =
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
let descs: Vec<(u32, u32, f32, f32)> = objs
.iter()
.map(|a| {
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
let tc = sh.col_at(x2);
let tr = sh.row_at(y2);
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
})
.collect();
let shm = cx.sheet_mut(sheet)?;
for (c, cell) in &moved {
match cell {
Some(x) => shm.set_cell(*c, x.clone()),
None => {
shm.remove_cell(*c);
}
}
}
// Every cell of the body is the destination of exactly one source cell, so clearing the
// body and inserting the permuted entries is the same permutation the cells went through.
for c in body.iter() {
shm.comments.remove(&c);
shm.hyperlinks.remove(&c);
}
for (at, cm) in side {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
shm.cell_pictures.extend(pictures);
for (i, h) in heights.into_iter().enumerate() {
let row = body.start.row + i as u32;
match h {
Some(info) => {
shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info });
}
None => {
if let Some(e) = shm.rows.get_mut(&row) {
e.size = None;
}
}
}
}
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
// Only objects anchored inside the sorted block move: within its lines on the sort axis
// and within its span on the other axis (a picture beside the block stays put).
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
let ni = shm.images.len();
let nch = shm.charts.len();
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
let other = if by_cols { a.cell.row } else { a.cell.col };
if other < o0 || other > o1 {
continue;
}
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
if na != a {
if i < ni {
if let Some(o) = shm.images.get_mut(i) {
o.anchor = na;
}
} else if i < ni + nch {
if let Some(o) = shm.charts.get_mut(i - ni) {
o.anchor = na;
}
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
o.anchor = na;
}
}
}
for (c, _) in moved {
cx.changed.push((sheet, c));
}
cx.structural = true;
Ok(Json::Null)
})?;
Ok(json!({"sorted": body.a1()}))
}
/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the
/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
}
let (tc, tr, tdx, tdy) = bottom;
let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } };
let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) };
if axis_start < b0 || axis_start > b1 {
return a;
}
let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) };
if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 {
let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) };
let x1 = sh.col_left(nc) + a.dx as f64;
let y1 = sh.row_top(nr) + a.dy as f64;
let x2 = sh.col_left(nbc) + tdx as f64;
let y2 = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
} else {
Anchor { cell: CellRef::new(nr, nc), ..a }
}
}
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
/// before uppercase at the first letter that differs in case.
fn case_order(a: &str, b: &str) -> Ordering {
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
}
pub(super) fn sort_key(v: &Value) -> (u8, i64, String) {
match v {
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
Value::Text(t) => (1, 0, t.to_lowercase()),
Value::Bool(b) => (2, *b as i64, String::new()),
Value::Empty => (9, 0, String::new()),
_ => (3, 0, v.display()),
}
}
+509
View File
@@ -0,0 +1,509 @@
use super::*;
#[test]
fn filter_blanks_include_empty_text_and_formula_results() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B5"})).unwrap();
let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();
assert_eq!(r["hiddenRows"], json!(2));
let vals = filter_values(&s.doc().unwrap().wb, 0, 1);
// The `=""` result joins (Blanks) instead of showing as its own empty entry.
assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}");
let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect();
assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]);
}
#[test]
fn subtotal_honours_header_false() {
// #131: with header:false the first data row was treated as a header and left out of its
// group's subtotal and the grand total.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!("East"));
assert_eq!(get(&mut s, "A3")["text"], json!("East Total"));
assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows");
assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total"));
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
}
#[test]
fn subtotal_puts_summary_rows_below_their_detail() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.summary_below, "the totals sit below their groups");
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
assert!(!hidden(&s, 3), "East Total stays visible");
// One undo takes the subtotals and the setting back.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(!sh.summary_below);
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "B1:C3"})).unwrap();
for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] {
let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err();
assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}");
}
let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap();
assert_eq!(ok["hiddenRows"], json!(1));
}
#[test]
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
s.execute("selection.set", sel.clone()).unwrap();
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
assert!(sh.tables[0].filter_button, "{sel}");
assert!(!sh.is_row_hidden(1), "{sel}");
assert!(sh.is_row_hidden(2), "{sel}");
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
}
}
#[test]
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
assert_eq!(range(&s), Some("A1:B4".to_string()));
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A row typed after a blank row does not grow it.
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A cell outside the filter's columns does not either.
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// Empty text is a blank entry, not a value.
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A formula and Ctrl+Enter (range.fill) are entries too.
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
assert_eq!(range(&s), Some("A1:B6".to_string()));
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
assert_eq!(range(&s), Some("A1:B7".to_string()));
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
s.new_workbook();
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
s.new_workbook();
let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi});
let ok =
|s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true);
s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap();
s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap();
assert!(ok(&mut s, "B2", "999"));
assert!(!ok(&mut s, "B1", "999"));
assert!(!ok(&mut s, "B3", "999"));
s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap();
for cell in ["B2", "B3", "B4"] {
assert!(ok(&mut s, cell, "150"), "{cell}");
assert!(!ok(&mut s, cell, "5"), "{cell}");
}
assert!(ok(&mut s, "B1", "5"));
assert!(!ok(&mut s, "B1", "150"));
}
#[test]
fn showing_detail_gives_zero_width_columns_their_width_back() {
// #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them.
let mut s = Session::new();
s.new_workbook();
let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() };
let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for c in 2..=4 {
sh.cols.insert(c, hidden);
}
sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() });
s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
for c in 2..=4 {
assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}");
}
// Hiding again hides them; the level buttons show zero-width columns too.
s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0);
Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden);
s.execute("data.showLevels", json!({"cols": 2})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width));
}
#[test]
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("data.group", json!({"rows": "2:4"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
let hidden = |s: &Session| -> Vec<u32> {
let sh = s.doc().unwrap().wb.active().unwrap();
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
};
let before = hidden(&s);
assert_eq!(before, [1, 2, 3, 9]);
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none());
assert_eq!(hidden(&s), before);
assert_eq!(row_state(&s, 4..=4), [(false, true)]);
}
#[test]
fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap();
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
s.execute("selection.set", json!({"range": "E1"})).unwrap();
// The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice.
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::<Vec<_>>(), [2, 9]);
}
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
fn nested_rows() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:7"})).unwrap();
s
}
/// (hidden, collapsed) of 0-based rows.
fn row_state(s: &Session, rows: std::ops::RangeInclusive<u32>) -> Vec<(bool, bool)> {
let sh = s.doc().unwrap().wb.active().unwrap();
rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect()
}
#[test]
fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Each step is one undoable command.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]);
}
#[test]
fn hide_detail_on_the_summary_row_collapses_the_group_above_it() {
let mut s = nested_rows();
s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]);
}
#[test]
fn show_levels_collapses_and_expands_to_a_level() {
let mut s = nested_rows();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]);
s.execute("data.showLevels", json!({"rows": 2})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]);
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
assert_eq!(row_state(&s, 4..=8), [(false, false); 5]);
// Columns are separate; no level at all is an error rather than a guess.
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.showLevels", json!({"cols": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden)));
assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed));
assert_eq!(row_state(&s, 4..=4), [(false, false)]);
assert!(s.execute("data.showLevels", json!({})).is_err());
}
#[test]
fn group_by_columns_for_a_block_of_cells() {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("data.group", json!({"by": "cols"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1)));
assert!(sh.rows.values().all(|l| l.outline == 0));
}
#[test]
fn group_shortcuts_are_the_windows_ones() {
let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(sc("data.group"), Some("Alt+Shift+Right"));
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
let mut s = nested_rows();
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// Hide Detail on the summary row collapses the group below it.
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
// Ungrouping the outer group clears its mark on the row above.
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
}
#[test]
fn changing_the_summary_direction_moves_collapsed_marks() {
let mut s = nested_rows();
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
assert!(col_marked(&s, 1), "columns keep their direction");
// One undo step per change; no params leaves the settings as they are.
s.execute("edit.undo", json!({})).unwrap();
assert!(row_state(&s, 3..=3)[0].1);
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
// Nested groups that end on one row share its mark: it moves to the outer group only.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
// Hide and Show Detail keep it collapsed.
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
// A group on the first row has no row above it to mark.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
for row in [0u64, 1 << 32] {
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
let err = s.execute("data.sort", params).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
}
}
#[test]
fn single_row_and_column_parameters() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
s.execute("home.insertColumns", json!({"cols": "B"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "C3"), json!(4.0));
s.execute("home.hideRows", json!({"rows": "1"})).unwrap();
s.execute("data.group", json!({"cols": "C"})).unwrap();
assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err());
s.execute("home.deleteRows", json!({"rows": "2"})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap();
assert_eq!(v(&mut s, "B2"), json!(4.0));
}
#[test]
fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() {
let cases: [(Json, [&str; 4]); 5] = [
(json!([6, 3]), ["abc", "def", "ghij", ""]),
(json!([3, 3]), ["abc", "defghij", "", ""]),
(json!([0]), ["abcdefghij", "", "", ""]),
(json!([20]), ["abcdefghij", "", "", ""]),
(json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]),
];
for (widths, want) in cases {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap();
s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let got: Vec<String> = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect();
assert_eq!(got, want, "{widths}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
}
#[test]
fn remove_duplicates_without_a_valid_column_is_refused() {
// `columns: []` (or only unusable names) used to compare nothing and delete every row
// but the first.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
for columns in [json!([]), json!(["ZZZ"])] {
let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err();
assert!(err.to_string().contains("columns"), "{err}");
}
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
}
#[test]
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
let rules = [
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
("C1", " 1,2,3 ", r#""1,2,3""#),
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
("F1", "$K$1:$K$3", "$K$1:$K$3"),
("G1", "=Colors", "Colors"),
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
];
for (cell, source, _) in rules {
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
}
let accepted = [
("A1", "In progress"),
("B1", "Closed"),
("C1", "2"),
("D1", r#"Say "hi""#),
("E1", "Green"),
("F1", "Blue"),
("G1", "Red"),
("H1", "Green"),
];
let check = |wb: &Workbook| {
for (cell, input) in accepted {
let at = CellRef::parse(cell).unwrap();
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
}
};
let wb = s.doc().unwrap().wb.clone();
check(&wb);
let sheet_xml = |wb: &Workbook| {
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
xml
};
let xml = sheet_xml(&wb);
for (cell, _, saved) in rules {
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
}
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
check(&reopened);
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
}
#[test]
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
// with no error.
let mut s = Session::new();
s.new_workbook();
for params in [
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
] {
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
assert!(e.contains("unknown"), "{params}: {e}");
}
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
for params in [
json!({"range": "A1", "type": "any"}),
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
] {
s.execute("data.validation", params).unwrap();
}
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert_eq!(sh.validations.len(), 3);
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
}
+193
View File
@@ -0,0 +1,193 @@
use super::{
Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json,
ok, str_param, target_range, target_sheet,
};
// ---------------------------------------------------------------- tools
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Rows and columns past the data are blank: only the part with data is compared (whole
// columns compared a million blank rows and reported them as removed duplicates).
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let cols: Vec<u32> = p
.get("columns")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
// With no column to compare, every row key is empty: the first row would survive and every
// other row would be deleted as a duplicate. A column outside the range compares data the
// user didn't select. Excel refuses both.
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
}
let start = r.start.row + header as u32;
let mut removed = 0;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut seen = std::collections::HashSet::new();
let mut keep: Vec<u32> = Vec::new();
// Cells are compared by the text they show, as in Excel: the same date in two formats
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
for row in start..=r.end.row {
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
if seen.insert(key) {
keep.push(row);
} else {
removed += 1;
}
}
let pictures: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
})
.collect();
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
// describe them move with them; the ones on removed rows are dropped with the row.
let notes: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
})
.collect();
let links: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
})
.collect();
let rows: Vec<Vec<Option<Cell>>> =
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
let shm = cx.sheet_mut(sheet)?;
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
shm.remove_cell(CellRef::new(row, c));
}
}
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
let at = CellRef::new(row, c);
shm.comments.remove(&at);
shm.hyperlinks.remove(&at);
}
}
for (i, cells) in rows.into_iter().enumerate() {
for (j, cell) in cells.into_iter().enumerate() {
if let Some(cell) = cell {
shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
}
}
}
shm.cell_pictures.extend(pictures);
for (at, cm) in notes {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
cx.changed.push((sheet, c));
}
Ok(())
})?;
let remaining = (r.end.row + 1 - start) as usize - removed;
Ok(json!({"removed": removed, "remaining": remaining}))
}
pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let sheet = target_sheet(s, p)?;
let mut delims: Vec<char> = p
.get("delimiters")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect())
.unwrap_or_else(|| vec!['\t']);
if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) {
delims.push(o);
}
let fixed: Option<Vec<usize>> =
p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect());
let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false);
let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"');
let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start);
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut out: Vec<(CellRef, String)> = Vec::new();
for (i, row) in (r.start.row..=r.end.row).enumerate() {
let text = sh.value(CellRef::new(row, r.start.col)).display();
let parts: Vec<String> = match &fixed {
Some(w) => {
let chars: Vec<char> = text.chars().collect();
let mut cuts = vec![0];
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
breaks.sort_unstable();
breaks.dedup();
cuts.extend(breaks);
cuts.push(chars.len());
cuts.windows(2)
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
.collect()
}
None => split_delimited(&text, &delims, consecutive, quote),
};
for (j, part) in parts.into_iter().enumerate() {
out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part));
}
}
for (c, t) in out {
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
let shm = cx.sheet_mut(sheet)?;
match cell {
Some(x) => shm.set_cell(c, x),
None => {
shm.remove_cell(c);
}
}
cx.changed.push((sheet, c));
}
Ok(())
})?;
ok()
}
pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_q = false;
let mut last_delim = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == quote {
if in_q && chars.peek() == Some(&quote) {
cur.push(quote);
chars.next();
} else {
in_q = !in_q;
}
continue;
}
if !in_q && delims.contains(&c) {
if consecutive && last_delim {
continue;
}
out.push(std::mem::take(&mut cur));
last_delim = true;
continue;
}
last_delim = false;
cur.push(c);
}
out.push(cur);
out
}
+293
View File
@@ -0,0 +1,293 @@
use super::{
Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad,
bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- validation
fn op_param(s: Option<&str>) -> CfOperator {
match s.unwrap_or("between") {
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"greater" | "greaterThan" => CfOperator::Greater,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
_ => CfOperator::Between,
}
}
/// `r` minus `cut`, as up to four non-overlapping rectangles.
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
let Some(i) = r.intersection(cut) else { return vec![r] };
let mut out = Vec::new();
if i.start.row > r.start.row {
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
}
if i.end.row < r.end.row {
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
}
if i.start.col > r.start.col {
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
}
if i.end.col < r.end.col {
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
}
out
}
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
}
}
validations.retain(|d| !d.ranges.is_empty());
}
pub(super) fn validation(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
if bool_param(p, "clear").unwrap_or(false) {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
remove_validation_cells(&mut sh.validations, &ranges);
Ok(Json::Null)
});
}
let Some(kind) = str_param(p, "type") else {
s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({})));
return ok();
};
let kind = match kind {
"whole" => ValidationKind::Whole,
"decimal" => ValidationKind::Decimal,
"list" => ValidationKind::List,
"date" => ValidationKind::Date,
"time" => ValidationKind::Time,
"textLength" => ValidationKind::TextLength,
"custom" => ValidationKind::Custom,
"any" => ValidationKind::Any,
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
};
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
return Err(bad("data.validation", "a list validation needs `formula1`"));
}
const OPERATORS: [&str; 12] = [
"between",
"notBetween",
"equal",
"notEqual",
"greater",
"greaterThan",
"less",
"lessThan",
"greaterOrEqual",
"greaterThanOrEqual",
"lessOrEqual",
"lessThanOrEqual",
];
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
}
let error_style = match str_param(p, "errorStyle") {
None | Some("stop") => ErrorStyle::Stop,
Some("warning") => ErrorStyle::Warning,
Some("information") => ErrorStyle::Information,
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
};
let f1 = str_param(p, "formula1").unwrap_or("");
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
let dv = Validation {
ranges: ranges.clone(),
kind,
op: op_param(str_param(p, "operator")),
f1,
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
input_title: str_param(p, "inputTitle").unwrap_or("").into(),
input_message: str_param(p, "inputMessage").unwrap_or("").into(),
show_input: true,
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
error_style,
show_error: true,
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
remove_validation_cells(&mut sh.validations, &ranges);
sh.validations.push(dv.clone());
Ok(Json::Null)
})
}
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
/// than a reference or a name to evaluate.
fn is_literal_list(f: &str) -> bool {
f.starts_with('"')
|| (!f.contains('!')
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
}
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
fn list_source(f: &str) -> String {
let t = f.trim();
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
return f.trim_start_matches('=').to_string();
}
format!("\"{}\"", t.replace('"', "\"\""))
}
/// List entries of a list validation (literal or from a range).
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
let f = dv.f1.trim();
if is_literal_list(f) {
// A quoted list is a string literal: `""` inside stands for one `"`.
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
Some(body) => body.replace("\"\"", "\""),
None => f.trim_matches('"').to_string(),
};
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
match gridcraft_calc::evaluate(wb, sheet, at, f) {
Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(),
v if !v.is_empty() && !v.is_error() => vec![v.display()],
_ => vec![],
}
}
/// Checks typed input against the validation of a cell. `Ok(None)` = valid.
pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> {
let sh = wb.sheet(sheet)?;
let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?;
if dv.kind == ValidationKind::Any || !dv.show_error {
return None;
}
if input.is_empty() && dv.allow_blank {
return None;
}
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
// The formulas are written for the first cell of the range and see the value being entered,
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
let entered;
let wb = if sh.value(at) == v {
wb
} else {
let mut w = wb.clone();
if let Some(sh) = w.sheet_mut(sheet) {
let style = sh.style_id(at);
sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() });
}
entered = w;
&entered
};
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
let num = |f: &str| eval(f).to_number().ok();
let ok = match dv.kind {
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
ValidationKind::TextLength => {
let n = input.chars().count() as f64;
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
}
_ => match v.as_f64() {
Some(n) => {
if dv.kind == ValidationKind::Whole && n.fract() != 0.0 {
false
} else {
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
}
}
None => false,
},
};
if ok {
return None;
}
let msg = if dv.error_message.is_empty() {
"This value doesn't match the data validation restrictions defined for this cell.".to_string()
} else {
dv.error_message.clone()
};
Some((dv.clone(), msg))
}
/// A formula written for `first`, as seen from `at` (relative references shifted).
fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
if first == at {
return f.to_string();
}
match gridcraft_formula::parse(f.trim_start_matches('=')) {
Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative(
e,
at.row as i64 - first.row as i64,
at.col as i64 - first.col as i64,
)),
Err(_) => f.to_string(),
}
}
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
pub(in super::super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
if first == to {
return f.to_string();
}
let moved = formula_at(f, first, to);
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
let eq = if f.starts_with('=') { "=" } else { "" };
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
}
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
let Some(a) = a else { return false };
match op {
CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)),
CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)),
CfOperator::Equal => n == a,
CfOperator::NotEqual => n != a,
CfOperator::Greater => n > a,
CfOperator::Less => n < a,
CfOperator::GreaterOrEqual => n >= a,
CfOperator::LessOrEqual => n <= a,
}
}
pub(super) fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let input = str_param(p, "input").unwrap_or("");
match check_validation(&d.wb, d.wb.active_sheet(), at, input) {
None => Ok(json!({"ok": true})),
Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})),
}
}
pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let mut bad_cells = Vec::new();
for dv in &sh.validations {
for r in &dv.ranges {
let r = sh.used_range().and_then(|u| r.intersection(&u));
let Some(r) = r else { continue };
for c in r.iter().take(100_000) {
let text = sh.input_text(c);
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
bad_cells.push(c.a1());
}
}
}
}
Ok(json!({"invalid": bad_cells}))
}
+4 -4
View File
@@ -1,10 +1,10 @@
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
use gridcraft_core::CellRef;
use gridcraft_model::*;
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![
@@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
let d = s.doc()?;
let si = d.wb.active_sheet;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
let dx = (minx - sh.col_left(cell.col)) as f32;
@@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
}
fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
let si = s.doc()?.wb.active_sheet;
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
use super::{
Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json,
locked_cell_error, str_param, target_range, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- entry
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
/// format). Inherits the destination's cell, row or column style, including its number
/// format, before parsing. Errors when the formula can't be parsed.
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
if input.is_empty() {
let c = Cell { value: Value::Empty, formula: None, style };
return Ok(if c.is_blank() { None } else { Some(c) });
}
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
if fmt_is_text {
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
}
let is_formula = input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
let body = input.strip_prefix('=').unwrap_or(input);
let expr = wb.name_call_case(parse_typed_formula(body)?);
check_arg_counts(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
// Formulas whose result is a date/time get a format from functions like TODAY().
if wb.styles.get(style).num_fmt.as_str() == "General" {
let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default();
let auto = if f.starts_with("TODAY(")
|| f.starts_with("DATE(")
|| f.starts_with("EDATE(")
|| f.starts_with("EOMONTH(")
|| f.starts_with("WORKDAY(")
{
Some("m/d/yyyy")
} else if f.starts_with("NOW(") {
Some("m/d/yyyy h:mm")
} else if f.starts_with("TIME(") {
Some("h:mm AM/PM")
} else {
None
};
if let Some(code) = auto {
cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code));
}
}
return Ok(Some(cell));
}
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
if parsed.format.is_none()
&& !input.contains('%')
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
&& let Value::Number(n) = parsed.value
&& n.abs() >= 1.0
{
parsed.value = Value::Number(n / 100.0);
}
let mut style = style;
if let Some(code) = parsed.format {
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
if cur == "General" {
style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code));
}
}
// Wrap text automatically when the input has line breaks (Alt+Enter).
if input.contains('\n') && !wb.styles.get(style).align.wrap {
style = wb.styles.derive(style, |s| s.align.wrap = true);
}
Ok(Some(Cell { value: parsed.value, formula: None, style }))
}
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
/// and refuses a formula that still doesn't parse.
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
// Excel closes missing parentheses for you.
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
}
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
/// unknown functions are skipped.
pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut bad = None;
expr.walk(&mut |e| {
if bad.is_none()
&& let gridcraft_formula::Expr::Call(name, args) = e
&& let Some(spec) = gridcraft_functions::lookup(name)
&& !spec.accepts(args.len())
{
let few = args.len() < spec.min_args;
bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature));
}
});
match bad {
Some(msg) => Err(EngineError::Other(msg)),
None => Ok(()),
}
}
pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
// Both spellings take a string, number or boolean, as typed (#697).
let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
&& sh.is_protected()
&& d.wb.styles.get(sh.style_id(at)).protection.locked
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
let active_sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
// the cell; a cell elsewhere (or on another sheet) gets a one-cell array.
let selected = cx.sel.current();
let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) };
// A table header can't hold an array's value, and a table resizes under it.
if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) {
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
}
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
if let Some(f) = cell.formula.as_mut() {
std::sync::Arc::make_mut(f).array = Some(range);
}
let sh = cx.sheet_mut(sheet)?;
// The array takes the whole range: other cells in it lose their contents.
for c in range.iter().skip(1).take(1_000_000) {
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
let style = old.style;
sh.set_cell(c, Cell { style, ..Cell::default() });
}
}
sh.set_cell(range.start, cell);
cx.touch(sheet, range.start);
return Ok(Json::Null);
}
let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?;
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
None => {
sh.remove_cell(at);
}
}
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
Ok(Json::Null)
})?;
let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
Ok(json!({"cell": at.a1(), "value": v.display()}))
}
pub(crate) fn json_to_input(v: &Json) -> String {
match v {
Json::Null => String::new(),
Json::Bool(b) => {
if *b {
"TRUE".into()
} else {
"FALSE".into()
}
}
Json::Number(n) => n.to_string(),
Json::String(s) => s.clone(),
other => other.to_string(),
}
}
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
let mut count = 0;
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
let cols: Vec<&Json> = match row.as_array() {
Some(a) => a.iter().collect(),
None => vec![row],
};
for (ci, v) in cols.iter().enumerate() {
let Some(at) = start.offset(ri as i64, ci as i64) else { continue };
let input = json_to_input(v);
let cell = match v {
Json::Number(n) => Some(Cell {
value: Value::number(n.as_f64().unwrap_or(0.0)),
formula: None,
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
}),
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
None => {
sh.remove_cell(at);
}
}
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
count += 1;
}
}
Ok(())
})?;
Ok(json!({"cells": count}))
}
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let input = str_param(p, "input").unwrap_or("").to_string();
let origin = s.doc()?.selection.active;
edit(s, |cx| {
// Each cell gets what typing the input there gives: the automatic number format where
// it's General, its own format otherwise, text where it's Text. That depends only on
// the cell's style, so the input is parsed once per style.
let mut by_style: std::collections::HashMap<StyleId, Option<Cell>> = std::collections::HashMap::new();
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
for at in r.iter() {
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
let base = match by_style.get(&style) {
Some(c) => c.clone(),
None => {
let c = input_to_cell(&input, sheet, at, &mut cx.wb)?;
by_style.insert(style, c.clone());
c
}
};
let mut cell = base.unwrap_or_default();
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
}
}
Ok(Json::Null)
})
}
+271
View File
@@ -0,0 +1,271 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if ranges.len() > 1 && cut {
return Err(multiple());
}
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let trim = |range: RangeRef| {
if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
}
};
let (wb, range, areas) = if ranges.len() > 1 {
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
result["html"] = Json::String(html);
}
Ok(result)
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
/// into one block at the first area's place, and that block. Formulas travel as their values, as
/// in Excel. `None` when the areas don't line up.
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
let first = *areas.first()?;
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
let mut sorted = areas.to_vec();
if across {
sorted.sort_by_key(|a| a.start.col);
} else if down {
sorted.sort_by_key(|a| a.start.row);
} else {
return None;
}
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
return None;
}
let anchor = sorted.first()?.start;
let (h, w) = if across {
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
} else {
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
};
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
let src = wb.sheet(sheet)?;
let mut out = wb.clone();
let dst = out.sheet_mut(sheet)?;
dst.take_cells(block);
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
}
} else {
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
}
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
Some((out, block))
}
pub(super) fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
pub(super) fn cut(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, true)
}
pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result<Json> {
s.clipboard = None;
s.format_painter = None;
ok()
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum What {
All,
Formulas,
Values,
Formats,
Comments,
Validation,
AllExceptBorders,
ColumnWidths,
FormulasAndNumberFormats,
ValuesAndNumberFormats,
}
pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
// External text (from the system clipboard) when it differs from ours.
if let Some(text) = str_param(p, "text")
&& s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n']))
{
return paste_text(s, p, text);
}
if s.clipboard.is_none() {
return Err(EngineError::Other("Nothing to paste.".into()));
}
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
}
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
// only as far as the text's own shape, so spans in the markup can't expand without bound.
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
None => {
sh.remove_cell(c);
}
}
cx.touch(sheet, c);
}
}
if h > 0 && w > 0 {
let end = at.offset_clamped(h as i64 - 1, w as i64 - 1);
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
}
Ok(())
})?;
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
let mut quoted = false;
let mut at_start = true;
while let Some(c) = chars.next() {
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
quoted = false;
}
} else {
field.push(c);
}
continue;
}
match c {
'"' if at_start => {
quoted = true;
at_start = false;
}
'\t' => {
row.push(std::mem::take(&mut field));
at_start = true;
}
'\r' => {}
'\n' => {
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
at_start = true;
}
_ => {
field.push(c);
at_start = false;
}
}
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
rows
}
+96
View File
@@ -0,0 +1,96 @@
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
// ---------------------------------------------------------------- fill
pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let mut r = target_range(s, p)?;
// Whole rows or columns fill only where the sheet has content (nothing on an empty sheet).
if r.is_full_cols() || r.is_full_rows() {
match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) {
Some(u) => r = u,
None => return Ok(Json::Null),
}
}
if r.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.fill", "range too large"));
}
// With one row selected, Fill Down copies from the row above (Excel behaviour).
if dr == 1 && r.height() == 1 {
r.start.row = r.start.row.saturating_sub(1);
}
if dc == 1 && r.width() == 1 {
r.start.col = r.start.col.saturating_sub(1);
}
let src = if dr == 1 {
RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col))
} else if dr == -1 {
RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end)
} else if dc == 1 {
RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col))
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
Ok(Json::Null)
})
}
pub(super) fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?;
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
let mode = match str_param(p, "mode").unwrap_or("series") {
"copy" => crate::fill::FillMode::Copy,
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => crate::fill::FillMode::Series,
};
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.autoFill", "range too large"));
}
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
// into whole columns (`A:A`).
if !crate::fill::holds_source_in_corner(src, target) {
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
}
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, target, mode)?;
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
Ok(Json::Null)
})
}
pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height());
let kind = str_param(p, "type").unwrap_or("linear").to_string();
let step = f64_param(p, "step").unwrap_or(1.0);
let stop = f64_param(p, "stop");
let unit = str_param(p, "dateUnit").unwrap_or("day").to_string();
edit(s, |cx| {
crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?;
Ok(Json::Null)
})
}
pub(super) fn flash_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?;
if n == 0 {
return Err(EngineError::Other(
"We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(),
));
}
Ok(json!({"filled": n}))
}
+184
View File
@@ -0,0 +1,184 @@
use super::cell::input_to_cell;
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
// ---------------------------------------------------------------- find & replace
fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool {
let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) };
let has_wild = w.contains(['*', '?']);
if has_wild {
let pat = if whole { w } else { format!("*{w}*") };
return wildcard(&h, &pat);
}
if whole { h == w } else { h.contains(&w) }
}
/// `*`/`?` wildcard match with `~` escapes.
pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
let t: Vec<char> = text.chars().collect();
let mut p: Vec<(char, bool)> = Vec::new();
let mut it = pat.chars();
while let Some(c) = it.next() {
if c == '~' {
if let Some(n) = it.next() {
p.push((n, true));
}
} else {
p.push((c, false));
}
}
let (mut ti, mut pi) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
ti += 1;
pi += 1;
} else if pi < p.len() && p[pi] == ('*', false) {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == ('*', false) {
pi += 1;
}
pi == p.len()
}
pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
if what.is_empty() {
return Err(bad("edit.find", "missing `what`"));
}
let case = bool_param(p, "matchCase").unwrap_or(false);
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let values = str_param(p, "lookIn") == Some("values");
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(false);
let workbook = str_param(p, "within") == Some("workbook");
s.last_find = Some(p.clone());
let d = s.doc()?;
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
let mut hits: Vec<(usize, CellRef, String)> = Vec::new();
for si in sheets {
let Some(sh) = d.wb.sheet(si) else { continue };
let mut cells: Vec<(CellRef, String)> = sh
.cells
.iter()
.filter_map(|(c, cell)| {
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
crate::display::cell_text(&d.wb, sh, c)
} else {
cell.input_text()
};
matches(&text, &what, case, whole).then_some((c, text))
})
.collect();
if by_cols {
cells.sort_by_key(|(c, _)| (c.col, c.row));
}
hits.extend(cells.into_iter().map(|(c, t)| (si, c, t)));
}
if hits.is_empty() {
return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}")));
}
if all {
let list: Vec<Json> =
hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect();
return Ok(json!({"count": list.len(), "results": list}));
}
// Next hit after the active cell.
let cur = (d.wb.active_sheet(), d.selection.active);
let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) };
let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned();
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
let dd = s.doc_mut()?;
dd.wb_switch_sheet(si);
dd.selection = crate::Selection::at(c);
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
}
pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
let with = str_param(p, "with").unwrap_or("").to_string();
if what.is_empty() {
return Err(bad("edit.replace", "missing `what`"));
}
let case = bool_param(p, "matchCase").unwrap_or(false);
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(true);
let workbook = str_param(p, "within") == Some("workbook");
let active = s.doc()?.selection.active;
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
let mut n = 0;
for si in sheets {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
for (c, text) in cells {
if !all && c != active {
continue;
}
if !matches(&text, &what, case, whole) {
continue;
}
let new = if whole {
with.clone()
} else if case {
text.replace(&what, &with)
} else {
replace_ci(&text, &what, &with)
};
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
None => {
sh.remove_cell(c);
}
}
cx.touch(si, c);
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"replaced": n}))
}
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
/// byte positions in the folded text still line up with the original's character boundaries.
fn folds_in_place(s: &str) -> bool {
s.chars().all(|c| {
let mut folded = c.to_lowercase();
let first = folded.next();
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
})
}
pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String {
let lower = text.to_lowercase();
let w = what.to_lowercase();
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
// boundary: a length check alone misses foldings that keep the total length while shifting
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
return text.replace(what, with);
}
let mut out = String::new();
let mut i = 0;
while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) {
out.push_str(text.get(i..i + pos).unwrap_or(""));
out.push_str(with);
i += pos + w.len();
}
out.push_str(text.get(i..).unwrap_or(""));
out
}
+199
View File
@@ -0,0 +1,199 @@
use super::selection::{select_region, selection_set};
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
/// How many references the Go To dialog keeps for going back to.
pub(super) const GO_TO_RECENT: usize = 10;
/// The most areas one Go To selects.
pub(super) const GO_TO_MAX_AREAS: usize = 2000;
/// How deep a name that refers to other names is followed.
const GO_TO_NAME_DEPTH: usize = 8;
pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
let d = s.doc()?;
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
if target.is_some_and(|t| t != sheet) {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
target = Some(sheet);
named |= by_name;
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
}
}
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
if bool_param(p, "record") == Some(true) && !named {
let d = s.doc_mut()?;
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
d.go_to_recent.insert(0, entry);
d.go_to_recent.truncate(GO_TO_RECENT);
}
Ok(out)
}
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
let mut chars = text.char_indices();
std::iter::from_fn(move || {
if done {
return None;
}
for (i, c) in chars.by_ref() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
let part = text.get(start..i).unwrap_or("").trim();
start = i + 1;
return Some(part);
}
_ => {}
}
}
done = true;
Some(text.get(start..).unwrap_or("").trim())
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
/// do not refer to a range are left out.
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> =
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
})
.collect();
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
out
}
pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("blanks");
let d = s.doc()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() };
if kind == "lastCell" {
let c = sheet.used_range().map(|r| r.end).unwrap_or_default();
return selection_set(s, &json!({"cell": c.a1()}));
}
if kind == "currentRegion" {
return select_region(s, &json!({}));
}
let mut hits: Vec<CellRef> = Vec::new();
if scope.count() > 4_000_000 {
return Err(bad("edit.goToSpecial", "range too large"));
}
for c in scope.iter() {
let cell = sheet.cell(c);
let v = sheet.value(c);
let ok = match kind {
"blanks" => v.is_empty(),
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
"comments" | "notes" => sheet.comments.contains_key(&c),
"errors" => v.is_error(),
"numbers" => v.is_number(),
"text" => v.is_text(),
"visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col),
"conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))),
"dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))),
_ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))),
};
if ok {
hits.push(c);
}
}
if hits.is_empty() {
return Err(EngineError::Other("No cells were found.".into()));
}
let ranges = coalesce(&hits);
let d = s.doc_mut()?;
d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges };
Ok(json!({"selection": d.selection.a1(), "count": hits.len()}))
}
+358
View File
@@ -0,0 +1,358 @@
use super::clipboard::What;
use super::{
Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param,
target_sheet,
};
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
}
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
let operand = new.value.clone();
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
&& (operand.is_number() || operand.is_empty())
{
new.value = match op {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0),
"divide" => Value::number(x / y),
_ => operand.clone(),
};
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
new.value = old.value.clone();
}
if let Some(formula) = &old.formula
&& let Ok(y) = operand.to_number()
&& (operand.is_number() || operand.is_empty())
&& let Some(symbol) = operation_symbol(op)
{
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
fn operation_symbol(op: &str) -> Option<char> {
match op {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
}
fn copy_merges(
cx: &mut crate::cmd::Ctx,
dest_sheet: usize,
src_sheet: &gridcraft_model::Sheet,
src: RangeRef,
at: CellRef,
what: What,
transpose: bool,
) -> Result<()> {
if !matches!(what, What::All | What::Formats) || transpose {
return Ok(());
}
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
for merge in merges {
let row_offset = at.row as i64 - src.start.row as i64;
let col_offset = at.col as i64 - src.start.col as i64;
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
let moved = RangeRef::new(start, end);
let sheet = cx.sheet_mut(dest_sheet)?;
sheet.merges.retain(|existing| !existing.intersects(&moved));
sheet.merges.push(moved);
}
}
Ok(())
}
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
let what = match str_param(p, "what").unwrap_or("all") {
"all" => What::All,
"formulas" => What::Formulas,
"values" => What::Values,
"formats" => What::Formats,
"comments" | "notes" => What::Comments,
"validation" => What::Validation,
"allExceptBorders" => What::AllExceptBorders,
"columnWidths" => What::ColumnWidths,
"formulasAndNumberFormats" => What::FormulasAndNumberFormats,
"valuesAndNumberFormats" => What::ValuesAndNumberFormats,
other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))),
};
let op = str_param(p, "operation").unwrap_or("none").to_string();
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
let transpose = bool_param(p, "transpose").unwrap_or(false);
let link = bool_param(p, "link").unwrap_or(false);
if link && !clip.areas.is_empty() {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
let src = clip.range;
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
// Tile the copy over a larger destination that is a multiple of its size.
let tile = sel.height() % h == 0
&& sel.width() % w == 0
&& !sel.is_single()
&& cell_param_on(s, p, "at", dest_sheet)?.is_none()
&& !sel.is_full_cols()
&& !sel.is_full_rows()
&& sel.count() <= 4_000_000;
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
let same_doc = s.doc()?.uid == clip.doc_uid;
let cut_move = clip.cut && same_doc && what == What::All;
let src_wb = clip.wb.clone();
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
let src_sheet_name = src_sheet.name.clone();
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
}
edit(s, |cx| {
// Styles from another workbook must be re-interned.
let mut style_map = std::collections::HashMap::new();
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
if same_doc {
return id;
}
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
};
let mut moved_formulas = Vec::new();
let mut moved_pictures = std::collections::BTreeMap::new();
if cut_move {
// Remove the source first (contents and formats), and fix references to it.
let source = cx.sheet_mut(clip.sheet)?;
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
let taken = source.take_cells(src);
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
// with the block: the destination is written from the clipboard snapshot taken
// before the cut, so removing them here loses nothing and leaves the old place
// clean instead of leaving one merged block and one note behind.
source.comments.retain(|c, _| !src.contains(*c));
source.hyperlinks.retain(|c, _| !src.contains(*c));
source.merges.retain(|m| !src.contains_range(m));
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
moved_formulas = taken;
}
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
st
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
// Numbers stored as text participate; other text and errors stay as they are.
apply_operation(&op, &old, &mut new);
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
// Merged areas inside the copied block travel with it.
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
let e = if dest_sheet == clip.sheet {
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
} else {
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
cx.structural = true;
}
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
Ok(())
})?;
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
// formats (cell styles and merged areas, no conditional formats), column widths and notes
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
// except for column widths and notes, which write no cells at all.
let passive =
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
if !same_doc && !passive {
let d = s.doc_mut()?;
d.trust = d.trust.receive(clip.trust);
}
if cut_move {
s.clipboard = None;
}
Ok(json!({"pasted": s.doc()?.selection.a1()}))
}
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
use gridcraft_formula::adjust::Edit;
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
// (a cut moves its arrays itself).
let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. });
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
if keys.is_empty() {
continue;
}
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
let Some(sheet) = wb.sheet_mut(si) else { continue };
for (c, f) in keys {
let Some(expr) = f.expr() else { continue };
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
let text = gridcraft_formula::print(&new);
let array = match f.array {
Some(a) if on_target => crate::cmd::sheet::map_range(a, e),
a => a,
};
if (text != f.text || array != f.array)
&& let Some(cell) = sheet.cells.get_mut(c)
{
let mut nf = Formula::from_expr(new);
nf.array = array;
nf.dynamic = f.dynamic;
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
n.formula = gridcraft_formula::print(&new);
}
}
}
+174
View File
@@ -0,0 +1,174 @@
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
target_sheet,
};
// ---------------------------------------------------------------- selection
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
let Some(p) = sheet.protection.as_ref() else { return true };
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
}
pub(super) fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("sheet").is_some() {
let i = target_sheet(s, p)?;
s.doc_mut()?.wb_switch_sheet(i);
} else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell"))
&& let (Some(_), _) = split_sheet(r)
{
let i = target_sheet(s, p)?;
s.doc_mut()?.wb_switch_sheet(i);
}
let ranges = target_ranges(s, p)?;
let active = cell_param(s, p, "active")?;
let d = s.doc_mut()?;
let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
if !cell_selectable(&d.wb, sh, sel.active) {
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
}
}
d.selection = sel;
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
}
impl crate::DocState {
/// Switches the active sheet, remembering each sheet's selection.
pub fn wb_switch_sheet(&mut self, i: usize) {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet() {
return;
}
let cur = self.wb.active_sheet();
if self.sheet_selections.len() < self.wb.sheets.len() {
self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default());
}
if let Some(slot) = self.sheet_selections.get_mut(cur) {
*slot = self.selection.clone();
}
// View state: set in place, so the workbook is neither copied nor made dirty.
self.wb.set_active_sheet(i);
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
}
}
pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0);
let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0);
let extend = bool_param(p, "extend").unwrap_or(false);
let jmp = bool_param(p, "jump").unwrap_or(false);
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &mut d.selection;
// Extension moves the far corner, not the active cell.
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
jump(sheet, from, dr.signum(), dc.signum())
} else {
let mut t = from.offset_clamped(dr, dc);
let mut guard = 0;
while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 {
let n = t.offset_clamped(dr.signum(), dc.signum());
if n == t {
break;
}
t = n;
guard += 1;
}
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
};
if let Some(m) = sheet.merge_at(to)
&& !extend
{
to = m.start;
}
if !extend && !cell_selectable(&d.wb, sheet, to) {
// Step on to the next selectable cell in the same direction; stay put if there is none.
let (sr, sc) = (dr.signum(), dc.signum());
let mut t = to;
let mut found = false;
if !jmp && (sr, sc) != (0, 0) {
for _ in 0..10_000 {
let n = t.offset_clamped(sr, sc);
if n == t {
break;
}
t = n;
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
found = true;
break;
}
}
}
if !found {
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
}
to = t;
}
if extend {
sel.extend_to(to);
sel.expand_merges(sheet);
} else {
*sel = crate::Selection::at(to);
if let Some(m) = sheet.merge_at(to) {
sel.ranges = vec![m];
}
}
Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}))
}
pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
let fwd = bool_param(p, "forward").unwrap_or(true);
let by_row = bool_param(p, "byRow").unwrap_or(false);
let d = s.doc_mut()?;
d.selection.cycle(fwd, by_row);
Ok(json!({"active": d.selection.active.a1()}))
}
pub(super) fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let region = current_region(sheet, d.selection.active);
let all = RangeRef::all();
let r = if region.is_single() || d.selection.current() == region { all } else { region };
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] };
Ok(json!({"selection": d.selection.a1()}))
}
pub(super) fn select_region(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let r = current_region(sheet, d.selection.active);
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] };
Ok(json!({"selection": d.selection.a1()}))
}
pub(super) fn select_row(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let r = d.selection.current();
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] };
Ok(json!({"selection": d.selection.a1()}))
}
pub(super) fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let r = d.selection.current();
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] };
Ok(json!({"selection": d.selection.a1()}))
}
+305
View File
@@ -0,0 +1,305 @@
use super::*;
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
/// formula and a hidden name.
fn go_to_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
s.execute("selection.set", json!({"sheet": 0})).unwrap();
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
};
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
define(&mut s, "Rate", "=0.07", "Workbook");
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
wb.names.push(gridcraft_model::DefinedName {
name: "Secret".into(),
scope: None,
formula: "Sheet1!$Z$9".into(),
comment: String::new(),
hidden: true,
});
s
}
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
/// that are not ranges, and hidden names, are not listed.
#[test]
fn go_to_lists_the_named_ranges_of_every_sheet() {
let mut s = go_to_workbook();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
assert_eq!(list["recent"], json!([]), "{list}");
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
assert_eq!(out["selection"], "C3", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// A name of several areas selects all of them.
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
}
/// A name matches in any case and any script, as a formula matches it.
#[test]
fn go_to_matches_a_sheet_name_in_any_case() {
let mut s = go_to_workbook();
s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap();
s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap();
assert_eq!(out["selection"], "D4", "{out}");
// Seen from Sheet2, its own name hides the workbook name it shares.
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
/// each with a comma ... Excel will highlight all the ranges."
#[test]
fn go_to_selects_every_reference_and_name_in_a_list() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
assert_eq!(out["selection"], "A1,B2", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
}
}
#[test]
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
assert_eq!(parts.next(), Some("A1"));
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
assert_eq!(parts.next(), None);
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
let mut s = go_to_workbook();
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
assert!(err.to_string().contains("at most"), "{err}");
let bad = format!("Total{}", ",".repeat(1_000_000));
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
}
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
#[test]
fn go_to_keeps_the_references_entered_newest_first() {
let mut s = go_to_workbook();
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
// Names are listed already; a call without `record` (the Name Box) is not kept.
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
// Going back from Sheet2 to a recent reference returns to its sheet.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
for i in 0..GO_TO_RECENT + 2 {
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
}
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
assert_eq!(get(&mut s, "A3")["value"], json!(true));
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap();
s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap();
s.execute("selection.set", json!({"range": "C2:C4"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "C3")["value"], json!(4.0));
// Past the result Excel shows #N/A; the old content of the range is replaced.
assert_eq!(get(&mut s, "C4")["text"], "#N/A");
let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text());
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
}
#[test]
fn an_array_entered_outside_the_selection_covers_only_its_cell() {
// Found by the command-sequence fuzzer: the array took the selection's range even with
// `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the
// selected cells were wiped.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap();
s.execute("selection.set", json!({"range": "K20:K22"})).unwrap();
s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap();
assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap()));
assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep"));
}
#[test]
fn wildcards() {
assert!(wildcard("hello", "h*o"));
assert!(wildcard("hello", "h?llo"));
assert!(!wildcard("hello", "h?lo"));
assert!(wildcard("a*b", "a~*b"));
assert!(!wildcard("axb", "a~*b"));
assert!(wildcard("", "*"));
}
#[test]
fn tsv() {
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
}
#[test]
fn paste_special_operations_keep_text_errors_and_formulas() {
let mut s = Session::new();
s.new_workbook();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}),
)
.unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], "123");
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
s.execute("selection.set", json!({"range": "A1:E1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.0));
assert_eq!(get(&mut s, "B1")["value"], "Total");
assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A");
assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1");
assert_eq!(get(&mut s, "D1")["value"], json!(8.0));
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
}
#[test]
fn cut_paste_that_overflows_the_grid_moves_nothing() {
// #997: the source was emptied first and the cells that fell off the grid were skipped.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed, the second row would be past the last row.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed it still fits in column XFD.
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
}
#[test]
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
// no longer line up with the original, and the old slicing dropped the tail.
let mut s = Session::new();
s.new_workbook();
let text = "\u{212A}\u{0130}\u{0130}";
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
}
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
use super::{
CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok,
str_param, target_ranges, target_sheet,
};
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
}
pub(super) fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
for r in &ranges {
if r.count() > 100_000 {
return Err(bad("insert.checkbox", "range too large"));
}
for c in r.iter() {
let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = cx.wb.styles.derive(st, |s| {
s.num_fmt = NumFmt::new("checkbox");
s.align.h = HAlign::Center;
});
let sh = cx.sheet_mut(si)?;
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
// cell starts unchecked.
let mut cell = sh.cell(c).cloned().unwrap_or_default();
if cell.value.is_empty() && cell.formula.is_none() {
cell.value = Value::Bool(false);
}
cell.style = new;
sh.set_cell(c, cell);
cx.touch(si, c);
}
}
Ok(Json::Null)
})
}
pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let cells: Vec<CellRef> = match cell_param_on(s, p, "cell", si)? {
Some(c) => vec![c],
None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::<Vec<_>>()).collect(),
};
edit(s, |cx| {
for c in cells {
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& cell.formula.is_none()
{
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
Value::Bool(b) if checkbox => Value::Bool(!b),
Value::Empty if checkbox => Value::Bool(true),
_ => continue,
};
cell.value = next;
cx.changed.push((si, c));
}
}
Ok(Json::Null)
})
}
pub(super) fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
let mut params = object_params(p);
if params.get("name").is_none() {
params["name"] = json!(s.doc()?.wb.theme.name.clone());
}
s.execute("pageLayout.theme", params)
}
pub(super) fn theme_fonts(s: &mut Session, p: &Json) -> Result<Json> {
let name = s.doc()?.wb.theme.name.clone();
let colors: Vec<String> = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect();
s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")}))
}
pub(super) fn background(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "clear").unwrap_or(false) {
let si = s.doc()?.wb.active_sheet();
return edit(s, |cx| {
cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background");
Ok(Json::Null)
});
}
let mut params = object_params(p);
params["alt"] = json!("Sheet background");
params["at"] = json!("A1");
s.execute("insert.picture", params)
}
pub(super) fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
let edge = str_param(p, "edge").unwrap_or("grid");
let preset = match edge {
"top" => "top",
"bottom" => "bottom",
"left" => "left",
"right" => "right",
"outline" => "outside",
_ => "all",
};
let mut params = object_params(p);
params["preset"] = json!(preset);
s.execute("home.borders", params)
}
pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active);
let style = d.wb.styles.get(sh.style_id(c)).clone();
edit(s, |cx| {
cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
cx.wb.cell_styles.push((name.clone(), style.clone()));
Ok(json!({"name": name}))
})
}
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
let mut seen: Vec<String> = Vec::new();
for (id, p) in s.journal.iter().rev() {
if id != "cell.set" {
continue;
}
let input = p.get("input").and_then(Json::as_str).unwrap_or("");
if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
for f in e.functions() {
if !seen.iter().any(|x| x == f) {
seen.push(f.to_string());
}
}
}
if seen.len() >= 10 {
break;
}
}
Ok(json!(seen))
}
pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
ok()
}
+262
View File
@@ -0,0 +1,262 @@
use super::{
CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit,
f64_param, json, split_sheet, str_param, u32_param,
};
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet"))
}
fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?;
f(c)?;
Ok(json!({"chart": c.id}))
})
}
pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
let el = str_param(p, "element").unwrap_or("title").to_string();
let on = bool_param(p, "on").unwrap_or(true);
let pos = str_param(p, "position").map(str::to_string);
with_chart(s, p, |c| {
match el.as_str() {
"title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) },
"legend" => {
c.legend = if !on {
LegendPos::None
} else {
match pos.as_deref() {
Some("top") => LegendPos::Top,
Some("left") => LegendPos::Left,
Some("right") => LegendPos::Right,
_ => LegendPos::Bottom,
}
}
}
"dataLabels" => c.data_labels = on,
"gridlines" => c.gridlines = on,
"axisTitles" => {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into()));
}
"smooth" => {
for s in &mut c.series {
s.smooth = on;
}
}
"trendline" => {
let only = u32_param(p, "series").map(|i| i as usize);
if only.is_some_and(|i| i >= c.series.len()) {
return Err(bad("chart.addElement", "no such series"));
}
let targets: Vec<usize> = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect();
if !on {
for i in targets {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.clear();
}
}
return Ok(());
}
let t = trendline_param(p)?;
let eligible: Vec<usize> =
targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect();
if eligible.is_empty() {
return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series"));
}
for i in eligible {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.push(t.clone());
}
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())
})
}
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
/// Element ▸ Trendline choices, plus the Format Trendline options).
fn trendline_param(p: &Json) -> Result<Trendline> {
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
let mut t = Trendline {
forward: num("forward"),
backward: num("backward"),
display_equation: bool_param(p, "displayEquation").unwrap_or(false),
display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false),
..Trendline::default()
};
t.kind = match str_param(p, "type").unwrap_or("linear") {
"linear" => TrendlineKind::Linear,
// Excel's Linear Forecast: a linear trendline projected two periods ahead.
"linearForecast" => {
if t.forward == 0.0 {
t.forward = 2.0;
}
TrendlineKind::Linear
}
"exponential" => TrendlineKind::Exponential,
"logarithmic" => TrendlineKind::Logarithmic,
"power" => TrendlineKind::Power,
"polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8),
"movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)),
other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))),
};
Ok(t)
}
pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {
let n = u32_param(p, "layout").unwrap_or(1);
with_chart(s, p, |c| {
let (legend, labels, grid, axes) = match n {
2 => (LegendPos::Top, true, false, false),
3 => (LegendPos::Bottom, false, true, false),
4 => (LegendPos::Bottom, true, false, false),
5 => (LegendPos::None, false, true, true),
6 => (LegendPos::Right, false, true, true),
_ => (LegendPos::Right, false, true, false),
};
c.legend = legend;
c.data_labels = labels;
c.gridlines = grid;
if axes {
c.x_title.get_or_insert_with(|| "Axis Title".into());
c.y_title.get_or_insert_with(|| "Axis Title".into());
} else {
c.x_title = None;
c.y_title = None;
}
Ok(())
})
}
pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
let explicit: Option<Vec<Color>> =
p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect());
let palette = str_param(p, "palette").unwrap_or("colorful").to_string();
with_chart(s, p, |c| {
for (i, se) in c.series.iter_mut().enumerate() {
se.color = match &explicit {
Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(),
_ => match palette.as_str() {
"monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))),
p if p.starts_with("accent") => {
let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1);
Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800)))
}
_ => None,
},
};
}
Ok(())
})
}
pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = crate::cmd::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series);
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
pub(super) fn move_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let target = match p.get("sheet").and_then(Json::as_str) {
Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?),
None => None,
};
let at = cell_param_on(s, p, "at", target.unwrap_or(si))?;
edit(s, |cx| {
let mut chart = cx.sheet_mut(si)?.charts.remove(ci);
if let Some(a) = at {
chart.anchor.cell = a;
}
let dest = target.unwrap_or(si);
cx.sheet_mut(dest)?.charts.push(chart);
Ok(Json::Null)
})
}
pub(super) fn format_series(s: &mut Session, p: &Json) -> Result<Json> {
let idx = u32_param(p, "series").unwrap_or(0) as usize;
let color = str_param(p, "color").and_then(Color::from_hex);
with_chart(s, p, |c| {
if let Some(se) = c.series.get_mut(idx) {
se.color = color;
}
Ok(())
})
}
+131
View File
@@ -0,0 +1,131 @@
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let ti = match str_param(p, "table") {
Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)),
None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)),
}
.ok_or_else(|| bad("table.resize", "select a cell in a table"))?;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
));
}
let mut cols = Vec::new();
for (i, h) in headers.iter().enumerate() {
let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() };
let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned();
cols.push(existing.unwrap_or(TableColumn {
name,
totals: TotalsFn::None,
totals_label: None,
formula: None,
unique_name: None,
xml: None,
}));
}
t.columns = cols;
t.range = r;
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
let bytes = if let Some(t) = str_param(p, "text") {
t.as_bytes().to_vec()
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({})));
return ok();
};
let text = String::from_utf8_lossy(&bytes);
// JSON array of objects → rows.
let rows: Vec<Vec<Json>> =
if text.trim_start().starts_with('[') {
let arr: Vec<Json> = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?;
let mut keys: Vec<String> = Vec::new();
for o in &arr {
if let Some(m) = o.as_object() {
for k in m.keys() {
if !keys.contains(k) {
keys.push(k.clone());
}
}
}
}
let mut out = vec![keys.iter().map(|k| json!(k)).collect::<Vec<_>>()];
for o in &arr {
out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect());
}
out
} else {
let delim = str_param(p, "delimiter")
.and_then(|d| d.chars().next())
.unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' });
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() };
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?;
let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) };
(u.start.row..=u.end.row)
.map(|r| {
(u.start.col..=u.end.col)
.map(|c| match sh.value(CellRef::new(r, c)) {
Value::Number(n) => json!(n),
Value::Bool(b) => json!(b),
Value::Empty => Json::Null,
v => json!(v.display()),
})
.collect()
})
.collect()
};
if rows.is_empty() || rows.len() > 1_048_576 {
return Err(EngineError::Other("Nothing to import.".into()));
}
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
});
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
s.execute("range.setValues", json!({"range": "A1", "values": rows}))?;
let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1));
let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true}));
let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()}));
Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w}))
}
pub(super) fn from_table_range(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r };
let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r);
from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::<String>()}))
}
pub(super) fn queries(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
Ok(Json::Array(
d.wb.sheets
.iter()
.flat_map(|sh| {
sh.tables
.iter()
.map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)}))
})
.collect(),
))
}
+148
View File
@@ -0,0 +1,148 @@
use super::chart::format_series;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
};
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
return with_shape(s, p, |sh| sh.fill = color);
}
format_series(s, p)
}
pub(super) fn shape_outline(s: &mut Session, p: &Json) -> Result<Json> {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
with_shape(s, p, |sh| sh.line = color)
}
fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<Json> {
let si = s.doc()?.wb.active_sheet();
let id = u32_param(p, "id");
edit(s, |cx| {
let shapes = &mut cx.sheet_mut(si)?.shapes;
let sh = match id {
Some(id) => shapes.iter_mut().find(|x| x.id == id),
None => shapes.last_mut(),
}
.ok_or_else(|| bad("shape", "no such shape"))?;
f(sh);
Ok(Json::Null)
})
}
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
}
let item = v.remove(i);
let j = match (forward, extreme) {
(true, true) => v.len(),
(false, true) => 0,
(true, false) => (i + 1).min(v.len()),
(false, false) => i.saturating_sub(1),
};
v.insert(j, item);
}
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
match kind.as_str() {
"chart" => {
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
reorder(&mut sh.charts, i, forward, extreme);
}
}
"image" => {
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
reorder(&mut sh.images, i, forward, extreme);
}
}
_ => {
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
reorder(&mut sh.shapes, i, forward, extreme);
}
}
}
Ok(Json::Null)
})
}
pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.charts {
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
match kind {
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
}
}
pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
let shc = sheet.clone();
anchors_of(sheet, &kind)
.into_iter()
.filter(|(id, _)| ids.contains(id))
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
.collect()
};
if geo.len() < 2 {
return Ok(Json::Null);
}
let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min);
let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max);
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
let shc = sheet.clone();
for (id, a) in anchors_of(sheet, &kind) {
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
let (mut x, mut y) = (g.1, g.2);
match how.as_str() {
"left" => x = left,
"right" => x = right - g.3 as f64,
"center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0,
"top" => y = top,
"bottom" => y = bottom - g.4 as f64,
_ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0,
}
let c = CellRef::new(shc.row_at(y), shc.col_at(x));
a.cell = c;
a.dx = (x - shc.col_left(c.col)) as f32;
a.dy = (y - shc.row_top(c.row)) as f32;
}
Ok(Json::Null)
})
}
pub(super) fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("flip").is_some_and(|f| !f.is_null()) {
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
}
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
+181
View File
@@ -0,0 +1,181 @@
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.") && !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()}));
}
s.recording = Some(s.journal.len());
Ok(json!({"recording": true}))
}
pub(super) fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string();
let cmds = p.get("commands").cloned().unwrap_or(json!([]));
if !cmds.is_array() {
return Err(bad("automate.newScript", "`commands` must be an array"));
}
edit(s, |cx| {
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
all[name.as_str()] = cmds.clone();
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(json!({"name": name}))
})
}
pub(super) fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let (wb, trusted) = (&d.wb, d.scripts_trusted);
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> = all
.as_object()
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
.unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false}));
}
Ok(Json::Array(list))
}
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
}
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
/// [`run_script`] drops any `OpenUrl` request a step raises.
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
// Scripts are scoped to their workbook: a step that switches the active window would
// point every later step at another open document, so those are refused too.
if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
return Some(format!("scripts can't pass a file `path` to `{id}`"));
}
None
}
pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
let trusted = match p.get("trusted") {
None => true,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
};
s.doc_mut()?.scripts_trusted = trusted;
Ok(json!({"trusted": trusted}))
}
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
let d = s.doc()?;
let all = stash_get(&d.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
return Err(bad(
"automate.runScript",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
));
}
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if let Some(why) = denied_script_step(id, &st["params"]) {
return Err(bad("automate.runScript", why));
}
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
// 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(_)))) {
let mut i = 0;
s.ui_requests.retain(|r| {
i += 1;
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
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}))
}
/// 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;
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> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
if let Some(o) = all.as_object_mut() {
o.remove(&name);
}
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
Ok(Json::Null)
})
}
+187
View File
@@ -0,0 +1,187 @@
use super::{
CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet,
stash_get, stash_map, stash_set, str_param, target_sheet, u32_param,
};
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
pub(super) fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
if let Some(sh) = wb.sheet_mut(si) {
sh.view = mode;
}
if !dirty {
d.saved = d.wb.clone();
}
Ok(json!({"view": mode.as_str()}))
}
pub(super) fn ruler(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.hide_ruler);
s.hide_ruler = !on;
Ok(json!({"on": on}))
}
/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current
/// list when they swap in another snapshot of the workbook.
pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>) {
let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned();
let keep = find(from);
if keep == find(to) {
return;
}
let wb = std::sync::Arc::make_mut(to);
wb.names.retain(|n| n.name != WATCH_NAME);
wb.names.extend(keep);
}
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_map(&d.wb, VIEWS_NAME);
if let Some(name) = str_param(p, "save") {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
Ok(())
})?;
} else if let Some(name) = str_param(p, "show") {
let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?;
if let Some(si) = v["sheet"].as_u64() {
s.execute("sheet.activate", json!({"sheet": si}))?;
}
if let Some(sel) = v["selection"].as_str() {
s.execute("selection.set", json!({"range": sel}))?;
}
if let Some(z) = v["zoom"].as_u64() {
s.execute("view.zoom", json!({"percent": z}))?;
}
if let Some(f) = v.get("freeze") {
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
_ => None,
};
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = freeze;
Ok(())
})?;
}
} else if let Some(name) = str_param(p, "delete") {
if let Some(o) = views.as_object_mut() {
o.remove(name);
}
let v = views.clone();
edit(s, |cx| {
stash_set(&mut cx.wb, VIEWS_NAME, &v);
Ok(())
})?;
}
Ok(stash_get(&s.doc()?.wb, VIEWS_NAME))
}
pub(super) fn split(s: &mut Session, _: &Json) -> Result<Json> {
let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some());
if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) }
}
pub(super) fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let id = s.new_window()?;
let doc = s.window(id).map(|w| w.doc_uid);
Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)}))
}
/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most
/// recent window), else the active window.
pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result<u64> {
let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?;
s.target_window(t).map_err(|e| bad(cmd, e.to_string()))
}
/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout,
/// and the interface moves its native windows ([`Session::window_list`] order, active first).
pub(super) fn arrange_all(s: &mut Session, p: &Json) -> Result<Json> {
let arrange = match str_param(p, "arrange").unwrap_or("tiled") {
a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a,
other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))),
};
let active = s.active_window();
let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid);
let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false);
let mut windows: Vec<u64> = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect();
if let Some(at) = windows.iter().position(|w| Some(*w) == active)
&& let Some(first) = windows.get_mut(..=at)
{
first.rotate_right(1);
}
Ok(json!({"arrange": arrange, "windows": windows}))
}
pub(super) fn hide_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = window_param(s, "view.hideWindow", p)?;
s.hide_window(id);
ok()
}
pub(super) fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
Some(window_param(s, "view.unhideWindow", p)?)
} else if let Some(i) = u32_param(p, "index") {
// A workbook's position in the document list.
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?;
s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid))
} else {
s.last_hidden_window()
};
if let Some(id) = id {
s.unhide_window(id);
}
ok()
}
pub(super) fn switch_windows(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
let id = window_param(s, "view.switchWindows", p)?;
s.unhide_window(id);
}
Ok(s.window_list())
}
pub(super) fn watch_window(s: &mut Session, p: &Json) -> Result<Json> {
let mut list: Vec<String> =
stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
let mut changed = false;
if let Some(a) = str_param(p, "add") {
if !list.iter().any(|x| x == a) {
list.push(a.to_string());
}
changed = true;
}
if let Some(r) = str_param(p, "remove") {
list.retain(|x| x != r);
changed = true;
}
if changed {
let v = json!(list.clone());
let d = s.doc_mut()?;
// The watch list is view state: don't add an undo step.
let wb = std::sync::Arc::make_mut(&mut d.wb);
stash_set(wb, WATCH_NAME, &v);
}
let d = s.doc()?;
let rows: Vec<Json> = list
.iter()
.map(|w| {
let (sheet, body) = split_sheet(w);
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet());
let c = CellRef::parse(body).unwrap_or_default();
let sh = d.wb.sheet(si);
json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))})
})
.collect();
Ok(Json::Array(rows))
}
+55 -15
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> {
@@ -12,8 +12,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", 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.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?, 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!(
@@ -25,7 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
always,
options
),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window),
cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window),
cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())),
]
}
@@ -69,9 +71,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
} else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
// workbook decrypted with its password, so Save never replaces the encrypted original with
@@ -114,7 +116,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
};
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path}));
}
let d = s.doc_mut()?;
@@ -160,7 +162,16 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
/// File › Close closes the active window; the workbook closes with its last window.
fn close(s: &mut Session, p: &Json) -> Result<Json> {
if !bool_param(p, "document").unwrap_or(false)
&& let Some(id) = s.active_window()
&& let Some(doc) = s.window(id).map(|w| w.doc_uid)
&& s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1
{
s.close_window(id);
return Ok(json!({"closed": true, "document": false}));
}
let force = bool_param(p, "force").unwrap_or(false);
let d = s.doc()?;
if d.is_dirty() && !force {
@@ -175,12 +186,16 @@ fn close(s: &mut Session, p: &Json) -> Result<Json> {
fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let d = s.doc()?;
let mut wb = (*d.wb).clone();
let wb = (*d.wb).clone();
if p.get("sheet").is_some() {
wb.active_sheet = target_sheet(s, p)?;
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()}))
}
@@ -221,10 +236,35 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
}
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {
return Err(bad("window.activate", "no such window"));
}
s.set_active(i);
let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) {
Some(i) => {
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?;
s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))?
}
None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => {
return Err(bad("window.activate", "missing `window`"));
}
None => super::extra::window_param(s, "window.activate", p)?,
};
s.unhide_window(id);
ok()
}
fn close_window(s: &mut Session, p: &Json) -> Result<Json> {
let id = super::extra::window_param(s, "window.close", p)?;
let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default();
if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 {
s.close_window(id);
return Ok(json!({"closed": true, "document": false}));
}
let force = bool_param(p, "force").unwrap_or(false);
let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::EngineError::NoDocument)?;
if d.is_dirty() && !force {
let title = d.display_title();
s.activate_window(id);
s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": title})));
return Ok(json!({"closed": false, "dirty": true}));
}
s.close_window(id);
Ok(json!({"closed": true, "document": true}))
}
+6 -6
View File
@@ -11,7 +11,7 @@ use gridcraft_functions::Timeline;
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
use serde_json::{Value as Json, json};
use super::*;
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";
@@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = match sh {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => d.wb.active_sheet,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
let r = if r.is_full_cols() || r.is_full_rows() {
@@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)>
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = d.selection.current();
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
(d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
}
};
if r.width() < 2 {
@@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let stats_col = width + 1;
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
let at = s.doc()?.wb.active_sheet;
let at = s.doc()?.wb.active_sheet();
let index = edit(s, |cx| {
let at = at.min(cx.wb.sheets.len());
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
@@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc += 1;
}
}
cx.wb.active_sheet = at;
cx.wb.set_active_sheet(at);
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(at)
@@ -502,7 +502,7 @@ mod tests {
assert_eq!(r["periods"], 4);
let wb = &s.doc().unwrap().wb;
// Inserted before the data sheet and activated.
assert_eq!(wb.active_sheet, 0);
assert_eq!(wb.active_sheet(), 0);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
+27 -438
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![
@@ -233,10 +236,23 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
// Formatting on a protected sheet keeps the cell's permission flags: a format call
// that flips `locked` from the format permission would authorize content edits that
// the password should still gate, so those flags may not change (Excel keeps that
// checkbox disabled while the sheet is protected).
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
macro_rules! guard_flags {
($old:expr, $new:expr) => {
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
return Err(EngineError::Other(FLAG_GUARD.into()));
}
};
}
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
for r in &ranges {
let full_rows = r.is_full_rows();
@@ -251,6 +267,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
info.and_then(|i| i.style).unwrap_or_default()
};
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
guard_flags!(cur, new);
let sh = cx.sheet_mut(sheet)?;
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
map.entry(line).or_default().style = Some(new);
@@ -260,6 +277,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
for (c, st) in &cells {
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
guard_flags!(*st, new);
cx.sheet_mut(sheet)?.set_style(*c, new);
}
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
@@ -276,6 +294,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
if new != st {
guard_flags!(st, new);
cx.sheet_mut(sheet)?.set_style(c, new);
}
}
@@ -786,445 +805,15 @@ fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result<Json> {
set_fmt(s, p, &new)
}
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
let _ = theme;
let mut s = Style::default();
let accent = |i: u8, tint: i16| Color::Theme(i, tint);
match name {
"Normal" => {}
"Good" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"Bad" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
"Neutral" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"Calculation" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Check Cell" => {
s.fill = Fill::solid(Color::rgb(0xA5, 0xA5, 0xA5));
s.font.color = Color::rgb(0xFF, 0xFF, 0xFF);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Double, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Explanatory Text" => {
s.font.italic = true;
s.font.color = Color::rgb(0x7F, 0x7F, 0x7F);
}
"Input" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xCC, 0x99));
s.font.color = Color::rgb(0x3F, 0x3F, 0x76);
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Linked Cell" => {
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::rgb(0xFF, 0x80, 0x01) };
}
"Note" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xFF, 0xCC));
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xB2, 0xB2, 0xB2) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Output" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0x3F, 0x3F, 0x3F);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Warning Text" => s.font.color = Color::rgb(0xFF, 0x00, 0x00),
"Title" => {
s.font.size = 18.0;
s.font.color = Color::Theme(3, 0);
s.font.name = theme.major_font.clone();
}
"Heading 1" => {
s.font.size = 15.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 0) };
}
"Heading 2" => {
s.font.size = 13.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 500) };
}
"Heading 3" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Medium, color: accent(4, 400) };
}
"Heading 4" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
}
"Total" => {
s.font.bold = true;
s.borders.top = BorderLine { style: BorderStyle::Thin, color: accent(4, 0) };
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: accent(4, 0) };
}
"Currency" => s.num_fmt = NumFmt::new(ACCOUNTING),
"Comma" => s.num_fmt = NumFmt::new(COMMA),
"Percent" => s.num_fmt = NumFmt::new("0%"),
"Currency [0]" => s.num_fmt = NumFmt::new("_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"),
"Comma [0]" => s.num_fmt = NumFmt::new("_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"),
n => {
// AccentN, 20%/40%/60% - AccentN
let (pct, rest) = match n.split_once("% - ") {
Some((p, r)) => (p.parse::<i16>().ok(), r),
None => (None, n),
};
let i: u8 = rest.strip_prefix("Accent")?.parse().ok()?;
if !(1..=6).contains(&i) {
return None;
}
match pct {
None => {
s.fill = Fill::solid(accent(3 + i, 0));
s.font.color = Color::Theme(0, 0);
}
Some(p) => {
s.fill = Fill::solid(accent(3 + i, (1000 - p * 10).clamp(0, 1000)));
s.font.color = if p >= 60 { Color::Theme(0, 0) } else { Color::Theme(1, 0) };
}
}
}
}
Some(s)
}
pub const CELL_STYLE_NAMES: &[&str] = &[
"Normal",
"Bad",
"Good",
"Neutral",
"Calculation",
"Check Cell",
"Explanatory Text",
"Input",
"Linked Cell",
"Note",
"Output",
"Warning Text",
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Title",
"Total",
"20% - Accent1",
"20% - Accent2",
"20% - Accent3",
"20% - Accent4",
"20% - Accent5",
"20% - Accent6",
"40% - Accent1",
"40% - Accent2",
"40% - Accent3",
"40% - Accent4",
"40% - Accent5",
"40% - Accent6",
"60% - Accent1",
"60% - Accent2",
"60% - Accent3",
"60% - Accent4",
"60% - Accent5",
"60% - Accent6",
"Accent1",
"Accent2",
"Accent3",
"Accent4",
"Accent5",
"Accent6",
"Comma",
"Comma [0]",
"Currency",
"Currency [0]",
"Percent",
];
fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("Normal").to_string();
let d = s.doc()?;
let style =
d.wb.cell_styles
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map(|(_, s)| s.clone())
.or_else(|| builtin_cell_style(&name, &d.wb.theme));
let Some(style) = style else { return Err(bad("home.cellStyle", format!("unknown cell style `{name}`"))) };
// Number-format styles only change the number format.
let only_fmt = matches!(name.as_str(), "Comma" | "Comma [0]" | "Currency" | "Currency [0]" | "Percent");
apply_style(s, p, move |st| {
if only_fmt {
st.num_fmt = style.num_fmt.clone();
} else {
let keep_fmt = st.num_fmt.clone();
*st = style.clone();
if style.num_fmt.as_str() == "General" {
st.num_fmt = keep_fmt;
}
}
})
}
// ---------------------------------------------------------------- sizes
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
let lines = body
.split(',')
.map(|part| {
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
let e = sh.rows.entry(r).or_default();
e.size = Some(h as f32);
e.hidden = h == 0.0;
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
let e = sh.cols.entry(c).or_default();
e.size = Some(w as f32);
e.hidden = w == 0.0;
}
}
Ok(Json::Null)
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
} else if c < 1.0 {
(c * 12.0).round()
} else {
(c * 7.0 + 5.0).round()
}
}
pub use gridcraft_model::points_to_chars;
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
w += match c {
'i' | 'l' | 'j' | '\'' | '.' | ',' | ':' | ';' | '!' | '|' => 0.28,
'f' | 't' | 'r' | 'I' | ' ' | '(' | ')' | '-' => 0.38,
'm' | 'w' | 'M' | 'W' => 0.85,
c if c.is_ascii_uppercase() => 0.65,
c if c.is_ascii_digit() => 0.56,
c if c.is_ascii() => 0.52,
_ => 1.0,
};
}
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
mod cell_styles;
mod sizes;
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
let Some(u) = used else { continue };
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
let mut max: f32 = 0.0;
for (cell, _) in sh.cells.iter_range(col) {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
widths.push((c, max.clamp(8.0, 1785.0)));
}
}
}
let sh = cx.sheet_mut(si)?;
for (c, w) in widths {
let e = sh.cols.entry(c).or_default();
e.size = Some(w.ceil());
e.hidden = false;
}
Ok(Json::Null)
})
}
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
for r in a..=b {
if let Some(e) = sh.rows.get_mut(&r) {
e.custom = false;
e.hidden = false;
}
}
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
// lines on both sides selected; we're forgiving).
let (a, b) = if !on && a == b { (a.saturating_sub(1), b + 1) } else { (a, b) };
for i in a..=b.min(a.saturating_add(1_048_575)) {
let map = if rows { &mut sh.rows } else { &mut sh.cols };
if on {
map.entry(i).or_default().hidden = true;
} else if let Some(e) = map.get_mut(&i) {
e.hidden = false;
if e.size == Some(0.0) {
e.size = None;
}
}
}
}
cx.structural = true; // SUBTOTAL(10x) depends on hidden rows
Ok(Json::Null)
})
}
use cell_styles::cell_style;
pub use cell_styles::{CELL_STYLE_NAMES, builtin_cell_style};
pub use sizes::{approx_text_width, chars_to_points};
use sizes::{autofit_cols, autofit_rows, col_width, default_width, hide, row_height};
#[cfg(test)]
mod tests {
+199
View File
@@ -0,0 +1,199 @@
use super::{
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
};
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
let _ = theme;
let mut s = Style::default();
let accent = |i: u8, tint: i16| Color::Theme(i, tint);
match name {
"Normal" => {}
"Good" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"Bad" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
"Neutral" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"Calculation" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Check Cell" => {
s.fill = Fill::solid(Color::rgb(0xA5, 0xA5, 0xA5));
s.font.color = Color::rgb(0xFF, 0xFF, 0xFF);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Double, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Explanatory Text" => {
s.font.italic = true;
s.font.color = Color::rgb(0x7F, 0x7F, 0x7F);
}
"Input" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xCC, 0x99));
s.font.color = Color::rgb(0x3F, 0x3F, 0x76);
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x7F, 0x7F, 0x7F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Linked Cell" => {
s.font.color = Color::rgb(0xFA, 0x7D, 0x00);
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::rgb(0xFF, 0x80, 0x01) };
}
"Note" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xFF, 0xCC));
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xB2, 0xB2, 0xB2) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Output" => {
s.fill = Fill::solid(Color::rgb(0xF2, 0xF2, 0xF2));
s.font.color = Color::rgb(0x3F, 0x3F, 0x3F);
s.font.bold = true;
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0x3F, 0x3F, 0x3F) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
"Warning Text" => s.font.color = Color::rgb(0xFF, 0x00, 0x00),
"Title" => {
s.font.size = 18.0;
s.font.color = Color::Theme(3, 0);
s.font.name = theme.major_font.clone();
}
"Heading 1" => {
s.font.size = 15.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 0) };
}
"Heading 2" => {
s.font.size = 13.0;
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Thick, color: accent(4, 500) };
}
"Heading 3" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
s.borders.bottom = BorderLine { style: BorderStyle::Medium, color: accent(4, 400) };
}
"Heading 4" => {
s.font.bold = true;
s.font.color = Color::Theme(3, 0);
}
"Total" => {
s.font.bold = true;
s.borders.top = BorderLine { style: BorderStyle::Thin, color: accent(4, 0) };
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: accent(4, 0) };
}
"Currency" => s.num_fmt = NumFmt::new(ACCOUNTING),
"Comma" => s.num_fmt = NumFmt::new(COMMA),
"Percent" => s.num_fmt = NumFmt::new("0%"),
"Currency [0]" => s.num_fmt = NumFmt::new("_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"),
"Comma [0]" => s.num_fmt = NumFmt::new("_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"),
n => {
// AccentN, 20%/40%/60% - AccentN
let (pct, rest) = match n.split_once("% - ") {
Some((p, r)) => (p.parse::<i16>().ok(), r),
None => (None, n),
};
let i: u8 = rest.strip_prefix("Accent")?.parse().ok()?;
if !(1..=6).contains(&i) {
return None;
}
match pct {
None => {
s.fill = Fill::solid(accent(3 + i, 0));
s.font.color = Color::Theme(0, 0);
}
Some(p) => {
s.fill = Fill::solid(accent(3 + i, (1000 - p * 10).clamp(0, 1000)));
s.font.color = if p >= 60 { Color::Theme(0, 0) } else { Color::Theme(1, 0) };
}
}
}
}
Some(s)
}
pub const CELL_STYLE_NAMES: &[&str] = &[
"Normal",
"Bad",
"Good",
"Neutral",
"Calculation",
"Check Cell",
"Explanatory Text",
"Input",
"Linked Cell",
"Note",
"Output",
"Warning Text",
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Title",
"Total",
"20% - Accent1",
"20% - Accent2",
"20% - Accent3",
"20% - Accent4",
"20% - Accent5",
"20% - Accent6",
"40% - Accent1",
"40% - Accent2",
"40% - Accent3",
"40% - Accent4",
"40% - Accent5",
"40% - Accent6",
"60% - Accent1",
"60% - Accent2",
"60% - Accent3",
"60% - Accent4",
"60% - Accent5",
"60% - Accent6",
"Accent1",
"Accent2",
"Accent3",
"Accent4",
"Accent5",
"Accent6",
"Comma",
"Comma [0]",
"Currency",
"Currency [0]",
"Percent",
];
pub(super) fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("Normal").to_string();
let d = s.doc()?;
let style =
d.wb.cell_styles
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map(|(_, s)| s.clone())
.or_else(|| builtin_cell_style(&name, &d.wb.theme));
let Some(style) = style else { return Err(bad("home.cellStyle", format!("unknown cell style `{name}`"))) };
// Number-format styles only change the number format.
let only_fmt = matches!(name.as_str(), "Comma" | "Comma [0]" | "Currency" | "Currency [0]" | "Percent");
apply_style(s, p, move |st| {
if only_fmt {
st.num_fmt = style.num_fmt.clone();
} else {
let keep_fmt = st.num_fmt.clone();
*st = style.clone();
if style.num_fmt.as_str() == "General" {
st.num_fmt = keep_fmt;
}
}
})
}
+245
View File
@@ -0,0 +1,245 @@
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
};
// ---------------------------------------------------------------- sizes
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
let lines = body
.split(',')
.map(|part| {
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
let d = s.doc()?;
if sheet != d.wb.active_sheet() {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
pub(super) fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
let e = sh.rows.entry(r).or_default();
e.size = Some(h as f32);
e.hidden = h == 0.0;
e.custom = true;
}
}
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
pub(super) fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
let e = sh.cols.entry(c).or_default();
e.size = Some(w as f32);
e.hidden = w == 0.0;
}
}
Ok(Json::Null)
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
} else if c < 1.0 {
(c * 12.0).round()
} else {
(c * 7.0 + 5.0).round()
}
}
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
w += match c {
'i' | 'l' | 'j' | '\'' | '.' | ',' | ':' | ';' | '!' | '|' => 0.28,
'f' | 't' | 'r' | 'I' | ' ' | '(' | ')' | '-' => 0.38,
'm' | 'w' | 'M' | 'W' => 0.85,
c if c.is_ascii_uppercase() => 0.65,
c if c.is_ascii_digit() => 0.56,
c if c.is_ascii() => 0.52,
_ => 1.0,
};
}
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
let Some(u) = used else { continue };
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
let mut max: f32 = 0.0;
for (cell, _) in sh.cells.iter_range(col) {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
widths.push((c, max.clamp(8.0, 1785.0)));
}
}
}
let sh = cx.sheet_mut(si)?;
for (c, w) in widths {
let e = sh.cols.entry(c).or_default();
e.size = Some(w.ceil());
e.hidden = false;
}
Ok(Json::Null)
})
}
pub(super) fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
for r in a..=b {
if let Some(e) = sh.rows.get_mut(&r) {
e.custom = false;
e.hidden = false;
}
}
}
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
}
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
Ok(Json::Null)
})
}
pub(super) fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
pub(super) fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
// lines on both sides selected; we're forgiving).
let (a, b) = if !on && a == b { (a.saturating_sub(1), b + 1) } else { (a, b) };
for i in a..=b.min(a.saturating_add(1_048_575)) {
let map = if rows { &mut sh.rows } else { &mut sh.cols };
if on {
map.entry(i).or_default().hidden = true;
} else if let Some(e) = map.get_mut(&i) {
e.hidden = false;
if e.size == Some(0.0) {
e.size = None;
}
}
}
}
cx.structural = true; // SUBTOTAL(10x) depends on hidden rows
Ok(Json::Null)
})
}
+260 -44
View File
@@ -1,10 +1,13 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook};
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![
@@ -55,6 +58,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
create_from_selection
),
cmd!(
"formulas.pasteName",
"Paste Names…",
["Formulas", "Defined Names"],
Some("F3"),
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
has_doc,
paste_name
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
@@ -87,42 +99,73 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
/// Whether a cell on the active sheet is empty.
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
sh.value(CellRef::new(row, col)).is_empty()
}
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
format!("={func}({})", RangeRef::new(from, to).a1())
}
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
// past it: to the right of a single row, below each column of anything taller. Columns
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), nor do rows past it, and all totals are one edit (one undo step).
let Some(used) = sh.cells.used_range() else { return Ok(None) };
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
}
/// One row: its total goes in its last cell when that is empty, else just after it.
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let row = sel.start.row;
let last_col = used.end.col.min(sel.end.col);
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
if out >= gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
}
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
}
/// A block of rows: each column's total below it, and each row's total in an empty last column.
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
let mut writes: Vec<(CellRef, String)> = Vec::new();
let last_col = used.end.col.min(sel.end.col);
// An empty last column takes each row's total; rows past the data get none.
let rows_end = sel.end.row.min(used.end.row);
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
// Each column's total, below it: unless only the last column is there to take totals.
if last_row_blank || !right_blank {
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
for c in sel.start.col..=data_end_col.min(last_col) {
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
}
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
if right_blank {
for r in sel.start.row..=data_end_row.min(rows_end) {
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
}
// Both empty: the corner takes the total of the whole block.
if last_row_blank {
writes.push((
CellRef::new(sel.end.row, sel.end.col),
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
));
}
}
Ok(writes)
}
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
let numeric = |c: CellRef| sh.value(c).is_number();
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
@@ -144,7 +187,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
let text = match range {
range
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
let sheet = d.wb.active_sheet();
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let text = match guessed_range(sh, at) {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
@@ -432,7 +501,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
.iter()
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
let sheet = n.scope.unwrap_or(d.wb.active_sheet());
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
@@ -510,10 +579,65 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet();
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
for n in shown(false) {
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
out.push((n.name.clone(), format!("={}", n.formula)));
}
}
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
out
}
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
if !bool_param(p, "list").unwrap_or(false) {
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
};
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
}
return Ok(json!({"names": listed}));
}
if names.is_empty() {
return Err(EngineError::Other("This workbook has no names to paste.".into()));
}
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet();
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
let c = CellRef::new(row, col);
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(c);
// The second column holds the refers-to text as a text value, with no formula.
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
cx.touch(sheet, c);
}
}
Ok(json!({"names": listed, "range": range.a1()}))
})
}
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
}
@@ -522,7 +646,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps);
deps.sort();
deps.dedup();
let v: Vec<Json> = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect();
@@ -535,7 +659,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Jso
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
@@ -606,7 +730,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
let on = bool_param(p, "on").unwrap_or(!cur);
edit(s, |cx| {
@@ -664,15 +788,19 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
}
}
}
let circ: Vec<String> =
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
let circ: Vec<String> = d
.calc
.circular
.iter()
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
.collect();
Ok(json!({"errors": out, "circular": circ}))
}
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
None => {
@@ -711,7 +839,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
@@ -877,4 +1005,92 @@ mod tests {
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
/// `Tax`, and a name of another sheet.
fn named_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
wb.names.push(name("TAX", Some(0), "0.25", false));
wb.names.push(name("Other", Some(1), "1", false));
s
}
fn text_at(s: &Session, cell: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
#[test]
fn paste_names_lists_the_names_the_active_sheet_can_use() {
let mut s = named_workbook();
let r = s.execute("formulas.pasteName", json!({})).unwrap();
assert_eq!(
r["names"],
json!([
{"name": "rate", "refersTo": "=0.07"},
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
{"name": "TAX", "refersTo": "=0.25"},
])
);
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
}
#[test]
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
let mut s = named_workbook();
s.take_ui_requests();
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
}
#[test]
fn paste_list_writes_each_name_and_what_it_refers_to() {
let mut s = named_workbook();
s.execute("selection.set", json!({"range": "D2"})).unwrap();
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
assert_eq!(r["range"], json!("D2:E4"));
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
for (cell, text) in rows {
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
}
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
s.execute("edit.undo", json!({})).unwrap();
for (cell, _) in rows {
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
}
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
}
#[test]
fn paste_list_needs_names_and_room() {
let mut s = named_workbook();
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
let mut empty = Session::new();
empty.new_workbook();
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
}
}
+36 -17
View File
@@ -3,11 +3,18 @@
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
Workbook,
};
use serde_json::{Value as Json, json};
use super::sheet::{rewrite, shift_cell_features};
use super::*;
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit,
f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use crate::selection::current_region;
pub fn specs() -> Vec<CommandSpec> {
@@ -92,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -294,7 +301,7 @@ 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)));
@@ -371,7 +378,7 @@ fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(n) = str_param(p, "table") {
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
}
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let ti = sh
.tables
@@ -582,6 +589,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
("pie", _) => ChartKind::Pie,
("doughnut", _) => ChartKind::Doughnut,
("area", "stacked") => ChartKind::AreaStacked,
("area", "stacked100") => ChartKind::AreaStacked100,
("area", _) => ChartKind::Area,
("scatter", "lines") => ChartKind::ScatterLines,
("scatter", _) => ChartKind::Scatter,
@@ -834,7 +842,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
@@ -983,7 +991,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
_ => SparklineKind::Line,
};
let markers = bool_param(p, "markers").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.sparklines.retain(|x| x.cell != loc);
@@ -1008,7 +1016,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
if placement == "cell" {
let mime = validate_cell_picture(&data)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let at = match str_param(p, "at") {
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
@@ -1052,7 +1060,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.images.push(img);
Ok(json!({"image": id}))
@@ -1122,7 +1130,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
@@ -1140,7 +1148,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
if shape.kind != ShapeKind::TextBox {
@@ -1163,7 +1171,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
@@ -1179,7 +1187,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
@@ -1220,7 +1228,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
}
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
@@ -1316,9 +1324,20 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
let named = str_param(p, "author").map(str::to_string);
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
let comments = &mut cx.sheet_mut(sheet)?.comments;
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
c.text = text.clone();
if let Some(author) = named.clone() {
c.author = author;
}
return Ok(json!({"cell": at.a1()}));
}
comments.insert(
at,
Comment {
author: author.clone(),
@@ -1718,7 +1737,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
+423
View File
@@ -0,0 +1,423 @@
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
};
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
return target_ranges(s, p);
}
let d = s.doc()?;
if d.selection.ranges.len() > 1 {
return Ok(d.selection.ranges.clone());
}
Ok(vec![block(s, p)?])
}
// ---------------------------------------------------------------- charts
pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
match (t, sub) {
("column", "stacked") => ChartKind::ColumnStacked,
("column", "stacked100") => ChartKind::ColumnStacked100,
("bar", "stacked") => ChartKind::BarStacked,
("bar", "stacked100") => ChartKind::BarStacked100,
("bar", _) => ChartKind::BarClustered,
("line", "markers") => ChartKind::LineMarkers,
("line", "stacked") => ChartKind::LineStacked,
("line", _) => ChartKind::Line,
("pie", _) => ChartKind::Pie,
("doughnut", _) => ChartKind::Doughnut,
("area", "stacked") => ChartKind::AreaStacked,
("area", "stacked100") => ChartKind::AreaStacked100,
("area", _) => ChartKind::Area,
("scatter", "lines") => ChartKind::ScatterLines,
("scatter", _) => ChartKind::Scatter,
("bubble", _) => ChartKind::Bubble,
("radar", _) => ChartKind::Radar,
("histogram", _) => ChartKind::Histogram,
("waterfall", _) => ChartKind::Waterfall,
("funnel", _) => ChartKind::Funnel,
("treemap", _) => ChartKind::Treemap,
("sunburst", _) => ChartKind::Sunburst,
("boxWhisker", _) => ChartKind::BoxWhisker,
("stock", _) => ChartKind::Stock,
("combo", _) => ChartKind::Combo,
_ => ChartKind::ColumnClustered,
}
}
/// Builds series from a block: first row/column as names/categories when they're text.
pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series> {
let q = gridcraft_formula::quote_sheet(&sh.name);
let abs = |r: RangeRef| {
format!(
"{q}!${}${}:${}${}",
gridcraft_core::col_to_letters(r.start.col),
r.start.row + 1,
gridcraft_core::col_to_letters(r.end.col),
r.end.row + 1
)
};
let abs1 = |c: CellRef| format!("{q}!${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty();
let header_row = r.height() > 1
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
// A blank top-left cell above a header row marks the first column as the category labels even
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
// plotted as a series of its own (#287).
let blank_corner = header_row && sh.value(r.start).is_empty();
let label_col =
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
let mut out = Vec::new();
if !by_rows {
for c in data.start.col..=data.end.col {
out.push(Series {
name: header_row.then(|| abs1(CellRef::new(r.start.row, c))),
categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))),
values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
}
} else {
for row in data.start.row..=data.end.row {
out.push(Series {
name: label_col.then(|| abs1(CellRef::new(row, r.start.col))),
categories: header_row
.then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))),
values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
}
}
out
}
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
if areas.len() == 1 {
let by_rows = first.width() > first.height() + 1;
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
}
let valid_shape = first.width() == 1
&& first.height() > 1
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
if !valid_shape {
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
}
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
let mut series = Vec::with_capacity(areas.len() - 1);
for area in &areas[1..] {
let mut area_series = series_from_range(sh, *area, false);
let Some(mut item) = area_series.pop() else {
return Err(EngineError::Other("Select data for the chart first.".into()));
};
if item.name.is_none() {
return Err(bad("insert.chart", "multi-area series columns require headers"));
}
item.categories = category.clone();
series.push(item);
}
Ok((bounds, false, series))
}
/// A scatter plot of a block without category labels takes its first column (or row) as the X
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() {
let x = series.remove(0).values;
for se in series.iter_mut() {
se.categories = Some(x.clone());
}
}
}
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
_ => None,
})
.collect();
let points = ranges.first().map_or(0, RangeRef::count);
let (mut any_number, mut non_negative) = (false, true);
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
any_number = true;
non_negative &= n >= 0.0;
}
if series.is_empty() || !any_number {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let labelled = series.first().is_some_and(|x| x.categories.is_some());
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
const LINE: (&str, &str, &str) = ("line", "", "Line");
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
const AREA: (&str, &str, &str) = ("area", "", "Area");
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
let mut out = Vec::new();
if series.len() == 2 && !labelled && !by_rows {
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
} else if series.len() == 1 {
if points > 12 {
out.extend([LINE, COLUMN, AREA]);
} else {
out.extend([COLUMN, BAR]);
if non_negative && points >= 2 {
out.extend([PIE, DOUGHNUT]);
}
out.push(LINE_MARKERS);
}
} else if points > 12 {
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
} else {
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
}
Ok(out)
}
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
if t == "auto" {
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
t = first.0.into();
sub = first.1.into();
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let kind = chart_kind(&t, &sub);
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
scatter_x_from_first(kind, &mut series);
let id = d.wb.next_object_id();
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
} else {
Some("Chart Title".into())
}
});
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
by_rows,
pivot: None,
};
Ok((sheet, chart))
}
pub(super) fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, chart) = build_chart(s, p)?;
let id = chart.id;
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
Ok(json!({"chart": id}))
})
}
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart"))
}
pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let at = cell_param_on(s, p, "at", si)?;
let quartiles = p
.get("quartiles")
.map(|v| match v.as_str() {
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
})
.transpose()?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
if let Some(t) = str_param(p, "type") {
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
}
if let Some(method) = quartiles {
for series in &mut c.series {
series.quartiles = method;
}
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(str::to_string);
}
if let Some(l) = str_param(p, "legend") {
c.legend = match l {
"none" => LegendPos::None,
"top" => LegendPos::Top,
"left" => LegendPos::Left,
"right" => LegendPos::Right,
_ => LegendPos::Bottom,
};
}
if let Some(v) = bool_param(p, "dataLabels") {
c.data_labels = v;
}
if let Some(v) = bool_param(p, "gridlines") {
c.gridlines = v;
}
if let Some(v) = u32_param(p, "style") {
c.style = v.clamp(1, 16);
}
if let Some(t) = p.get("xTitle") {
c.x_title = t.as_str().map(str::to_string);
}
if let Some(t) = p.get("yTitle") {
c.y_title = t.as_str().map(str::to_string);
}
if let Some(at) = at {
c.anchor.cell = at;
}
if let Some(w) = f64_param(p, "width") {
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
}
if let Some(h) = f64_param(p, "height") {
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
}
if let Some(v) = f64_param(p, "dx") {
c.anchor.dx = v as f32;
}
if let Some(v) = f64_param(p, "dy") {
c.anchor.dy = v as f32;
}
Ok(Json::Null)
})
}
pub(super) fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
// Charts read from a file have no source range: take the block their series span.
let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) };
let (sheet_name, body) = split_sheet(&src);
let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) };
let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
// Whole-column/row references (common in files) span a million cells: clip the block's
// far edges to the cells in use, then refuse anything still too large to chart.
if let Some(used) = ssh.used_range() {
r.end.row = r.end.row.min(used.end.row).max(r.start.row);
r.end.col = r.end.col.min(used.end.col).max(r.start.col);
}
if r.count() > MAX_CHART_CELLS {
return Err(bad("chart.switchRowColumn", "range too large for a chart"));
}
let by_rows = !by_rows;
let series = series_from_range(ssh, r, by_rows);
if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
c.source.get_or_insert(src);
c.by_rows = by_rows;
c.series = series;
scatter_x_from_first(c.kind, &mut c.series);
}
Ok(Json::Null)
})
}
/// The block a chart's series come from, and whether each series is a row of it: the bounding
/// range of the series names, categories and values, when they're all references to one sheet.
fn series_block(series: &[Series]) -> Option<(String, bool)> {
let mut sheet: Option<String> = None;
let mut bounds: Option<RangeRef> = None;
let mut rows = 0;
for s in series {
for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() {
let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None };
let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None };
if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) {
return None;
}
let range = r.range();
if i == 0 && range.height() == 1 && range.width() > 1 {
rows += 1;
}
bounds = Some(bounds.map_or(range, |b| b.union(&range)));
}
}
Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len()))
}
pub(super) fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
cx.sheet_mut(si)?.charts.remove(ci);
Ok(Json::Null)
})
}
+342
View File
@@ -0,0 +1,342 @@
use super::{
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
};
// ---------------------------------------------------------------- conditional formatting
fn preset_style(name: &str) -> Style {
let mut s = Style::default();
match name {
"redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06),
"yellowFill" | "yellowFillDarkYellowText" => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
}
"greenFill" | "greenFillDarkGreenText" => {
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
s.font.color = Color::rgb(0x00, 0x61, 0x00);
}
"lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)),
"redBorder" => {
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) };
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
}
_ => {
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
}
}
s
}
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
fn is_string_literal(v: &str) -> bool {
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
}
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
fn cf_style_json(st: &Json) -> Result<Json> {
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
let err = |m: String| bad("home.conditionalFormat", m);
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
let mut st = obj.clone();
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
}
// A colour written the way the other formatting commands take it, as the model stores it.
let color = |v: &mut Json, what: &str| -> Result<()> {
if v.is_string() || v.get("theme").is_some() {
let c = crate::cmd::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(())
};
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
&& let Some(c) = f.get_mut("color")
{
color(c, "font.color")?;
}
if let Some(fill) = st.get_mut("fill") {
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
if fill.is_string() && crate::cmd::format::color_param(Some(fill)) == Some(Color::Auto) {
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
} else if fill.is_string() || fill.get("theme").is_some() {
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
}
if let Some(f) = fill.as_object_mut() {
for k in ["fg", "bg"] {
if let Some(c) = f.get_mut(k) {
color(c, &format!("fill.{k}"))?;
}
}
// A colour with no pattern is a solid fill, not an invisible one.
if f.contains_key("fg") && !f.contains_key("pattern") {
f.insert("pattern".into(), json!("Solid"));
}
}
}
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
for (side, line) in b.iter_mut() {
if let Some(c) = line.get_mut("color") {
color(c, &format!("borders.{side}.color"))?;
}
}
}
Ok(Json::Object(st))
}
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
// only parses after several strips and would still be saved unparseable. (#1999 review)
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
}
Ok(())
}
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
check_cf_formula("home.conditionalFormat", what, f)?;
}
Ok(())
}
fn check_cf_rule(rule: &CfRule) -> Result<()> {
const CMD: &str = "home.conditionalFormat";
match rule {
CfRule::CellIs { a, b, .. } => {
check_cf_formula(CMD, "comparison value", a)?;
if let Some(b) = b {
check_cf_formula(CMD, "second comparison value", b)?;
}
}
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
CfRule::DataBar { min, max, .. } => {
check_cf_value("data bar minimum", min)?;
check_cf_value("data bar maximum", max)?;
}
CfRule::ColorScale { stops } => {
for (kind, _) in stops {
check_cf_value("color scale threshold", kind)?;
}
}
_ => {}
}
Ok(())
}
pub(super) fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
let Some(rule) = p.get("rule") else {
s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({})));
return ok();
};
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
let style = Box::new(match rule.get("style") {
Some(st) if !st.is_null() => {
let st = cf_style_json(st)?;
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
serde_json::from_value(crate::cmd::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
}
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
});
let kind = g("type").unwrap_or_else(|| "cellIs".into());
let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind {
match v {
Some(Json::Object(o)) => {
let t = o.get("type").and_then(Json::as_str).unwrap_or("num");
let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0);
match t {
"min" => CfValueKind::Min,
"max" => CfValueKind::Max,
"percent" => CfValueKind::Percent(val),
"percentile" => CfValueKind::Percentile(val),
"formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()),
_ => CfValueKind::Number(val),
}
}
_ => default,
}
};
let rule = match kind.as_str() {
"cellIs" => {
// Excel's file and VBA names (`lessThan`, …) are accepted too.
let op = match g("operator").as_deref().unwrap_or("greater") {
"between" => CfOperator::Between,
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
"greater" | "greaterThan" => CfOperator::Greater,
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
};
let a = rule.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(crate::cmd::edit::json_to_input);
let quote = |v: String| {
// A number, a formula, or text already written as a string literal (`"Closed"`, as
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
v.trim_start_matches('=').to_string()
} else if is_string_literal(&v) {
v
} else {
format!("\"{}\"", v.replace('"', "\"\""))
}
};
CfRule::CellIs { op, a: quote(a), b: b.map(quote), style }
}
"expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style },
"containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style },
"notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style },
"beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style },
"endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style },
"blanks" => CfRule::Blanks { style },
"noBlanks" => CfRule::NoBlanks { style },
"errors" => CfRule::Errors { style },
"noErrors" => CfRule::NoErrors { style },
"duplicate" => CfRule::Duplicate { style },
"unique" => CfRule::Unique { style },
"top10" => CfRule::Top10 {
bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false),
percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false),
rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32,
style,
},
"aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style },
"timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style },
"colorScale" => {
let cols: Vec<Color> = rule
.get("colors")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| crate::cmd::format::color_param(Some(c))).collect())
.unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]);
let stops = match cols.len() {
2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])],
_ => vec![
(CfValueKind::Min, cols.first().copied().unwrap_or_default()),
(CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()),
(CfValueKind::Max, cols.get(2).copied().unwrap_or_default()),
],
};
CfRule::ColorScale { stops }
}
"dataBar" => CfRule::DataBar {
min: value_kind(rule.get("min"), CfValueKind::Min),
max: value_kind(rule.get("max"), CfValueKind::Max),
color: crate::cmd::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)),
gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true),
show_value: true,
},
"iconSet" => CfRule::IconSet {
set: g("set").unwrap_or_else(|| "3TrafficLights1".into()),
thresholds: vec![],
reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false),
show_value: true,
},
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
};
check_cf_rule(&rule)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
// New rules take precedence over older ones.
for cf in sh.cond_formats.iter_mut() {
cf.priority += 1;
}
sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false });
let _ = priority;
Ok(json!({"rules": sh.cond_formats.len()}))
})
}
pub(super) fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let whole = bool_param(p, "sheet").unwrap_or(false);
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if whole {
sh.cond_formats.clear();
} else {
for cf in sh.cond_formats.iter_mut() {
cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r)));
}
sh.cond_formats.retain(|c| !c.ranges.is_empty());
}
Ok(Json::Null)
})
}
pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() {
let list: Vec<Json> = s
.doc()?
.wb
.sheet(sheet)
.map(|sh| {
sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), "rule": c.rule})).collect()
})
.unwrap_or_default();
return Ok(json!({"rules": list}));
}
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
// The count is taken again after each step: a delete shortens the list.
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
&& i < sh.cond_formats.len()
{
sh.cond_formats.remove(i);
}
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
&& i > 0
&& i < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i - 1);
}
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
&& i + 1 < sh.cond_formats.len()
{
sh.cond_formats.swap(i, i + 1);
}
for (k, cf) in sh.cond_formats.iter_mut().enumerate() {
cf.priority = k as u32 + 1;
}
Ok(Json::Null)
})
}
pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("check").to_string();
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
fill: color,
line: Color::Auto,
text: name,
format: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
+160
View File
@@ -0,0 +1,160 @@
use super::{
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
split_sheet, str_param, target_ranges, target_sheet,
};
// ---------------------------------------------------------------- links & comments
pub(super) fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
if bool_param(p, "remove").unwrap_or(false) {
return edit(s, |cx| {
cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
Ok(Json::Null)
});
}
let Some(target) = str_param(p, "target").map(str::to_string) else {
s.ui_requests.push(crate::UiRequest::Dialog("insertLink".into(), json!({})));
return ok();
};
let text = str_param(p, "text").map(str::to_string);
let tooltip = str_param(p, "tooltip").map(str::to_string);
let external = links_outside(&s.doc()?.wb, sheet, &target);
edit(s, |cx| {
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
st.font.color = Color::Theme(10, 0);
st.font.underline = Underline::Single;
});
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
} else {
sh.set_style(at, link_style);
}
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
cx.touch(sheet, at);
Ok(Json::Null)
})
}
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
/// dialog; the interactive click asks for confirmation first, in the UI.
pub(super) fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
Some(t) => t.to_string(),
None => {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
};
h.target.clone()
}
};
match crate::links::classify(&target) {
crate::links::LinkKind::Internal(r) => crate::cmd::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
crate::links::LinkKind::External(u) => {
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
Ok(json!({"opened": u}))
}
crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
}
}
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
/// Page") is outside the workbook and saved as a relationship.
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
let t = target.trim();
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
return false;
}
let (on, body) = split_sheet(t);
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
let on_sheet = on.is_none_or(|name| {
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
});
!(on_sheet && RangeRef::parse(body).is_some())
}
pub(super) fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let named = str_param(p, "author").map(str::to_string);
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
let comments = &mut cx.sheet_mut(sheet)?.comments;
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
c.text = text.clone();
if let Some(author) = named.clone() {
c.author = author;
}
return Ok(json!({"cell": at.a1()}));
}
comments.insert(
at,
Comment {
author: author.clone(),
text: text.clone(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
Ok(json!({"cell": at.a1()}))
})
}
pub(super) fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?;
c.replies.push((author.clone(), text.clone()));
Ok(Json::Null)
})
}
pub(super) fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
let v = bool_param(p, "resolved").unwrap_or(true);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.resolved = v;
}
Ok(Json::Null)
})
}
pub(super) fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c)));
Ok(Json::Null)
})
}
pub(super) fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
edit(s, |cx| {
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
c.visible = !c.visible;
}
Ok(Json::Null)
})
}
+267
View File
@@ -0,0 +1,267 @@
use super::{
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
};
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
let kind = match str_param(p, "type") {
Some("column") => SparklineKind::Column,
Some("winLoss") => SparklineKind::WinLoss,
_ => SparklineKind::Line,
};
let markers = bool_param(p, "markers").unwrap_or(false);
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
sh.sparklines.retain(|x| x.cell != loc);
sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers });
Ok(Json::Null)
})
}
pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
let placement = str_param(p, "placement").unwrap_or("overCells");
if !matches!(placement, "cell" | "overCells") {
return Err(bad("insert.picture", "placement must be cell or overCells"));
}
let data = if let Some(b) = str_param(p, "base64") {
crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
} else if let Some(path) = str_param(p, "path") {
crate::io::read_file(path)?
} else {
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement})));
return ok();
};
if placement == "cell" {
let mime = validate_cell_picture(&data)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let at = match str_param(p, "at") {
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
None => d.selection.active,
};
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
let style = sh.style_id(at);
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
}
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
return edit(s, |cx| {
cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture));
cx.touch(sheet, at);
Ok(json!({"cell": at.a1(), "placement": "cell"}))
});
}
if data.len() > 64 * 1024 * 1024 {
return Err(bad("insert.picture", "image larger than 64 MB"));
}
let mime = str_param(p, "mime")
.map(str::to_string)
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
let img = Image {
id,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
},
data: data.into(),
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.images.push(img);
Ok(json!({"image": id}))
})
}
/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode.
fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
if data.len() > 16 * 1024 * 1024 {
return Err(bad("insert.picture", "cell pictures must be at most 16 MiB"));
}
let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?;
let mime = match format {
image::ImageFormat::Png => "image/png",
image::ImageFormat::Jpeg => "image/jpeg",
_ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")),
};
let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format)
.into_dimensions()
.map_err(|_| bad("insert.picture", "invalid image dimensions"))?;
if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 {
return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels"));
}
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?;
Ok(mime)
}
pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
"roundedRectangle" => ShapeKind::RoundedRectangle,
"ellipse" | "oval" => ShapeKind::Ellipse,
"triangle" => ShapeKind::Triangle,
"line" => ShapeKind::Line,
"arrow" => ShapeKind::Arrow,
"textBox" => ShapeKind::TextBox,
_ => ShapeKind::Rectangle,
};
let d = s.doc()?;
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let fill = crate::cmd::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox {
Color::rgb(255, 255, 255)
} else {
Color::Theme(4, 0)
});
let line = crate::cmd::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
Color::rgb(0x80, 0x80, 0x80)
} else {
Color::Theme(4, -250)
});
let shape = Shape {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(144.0) as f32,
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
fill,
line,
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
/// A text box holds at most as many characters as a cell.
const SHAPE_TEXT_MAX_CHARS: usize = 32_767;
pub(super) fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
let id =
p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?;
let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?;
if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() {
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
if shape.kind != ShapeKind::TextBox {
return Err(bad("shape.setText", "the shape is not a text box"));
}
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
}
if shape.text == text {
return ok();
}
edit(s, |cx| {
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
shape.text = text.into();
Ok(Json::Null)
})
}
pub(super) fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
match kind.as_str() {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
"slicer" => sh.slicers.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
})
}
pub(super) fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let sheet = s.doc()?.wb.active_sheet();
let at = cell_param(s, p, "at")?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
if let Some(at) = at {
a.cell = at;
}
if let Some(v) = f64_param(p, "dx") {
a.dx = v as f32;
}
if let Some(v) = f64_param(p, "dy") {
a.dy = v as f32;
}
if let Some(v) = f64_param(p, "width") {
a.width = v.clamp(1.0, 10000.0) as f32;
}
if let Some(v) = f64_param(p, "height") {
a.height = v.clamp(0.0, 10000.0) as f32;
}
Ok(Json::Null)
})
}
/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
/// when the sheet is sorted or edited structurally.
pub(super) fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
}
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
};
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
a.mode = mode;
Ok(json!({"mode": anchor_mode_name(mode)}))
})
}
+334
View File
@@ -0,0 +1,334 @@
use super::{
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
};
// ---------------------------------------------------------------- tables
pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let r = block(s, p)?;
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let arrays = |sh: &gridcraft_model::Sheet| {
sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r)))
};
if d.wb.sheet(sheet).is_some_and(arrays) {
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
}
if r.count() > 50_000_000 {
return Err(bad("insert.table", "range too large"));
}
let header = bool_param(p, "header").unwrap_or_else(|| {
let sh = d.wb.sheet(sheet);
sh.is_some_and(|sh| (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && r.height() > 1)
});
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
// Without a header row, the data moves down a row to make room for one: never a table with it.
let moves_a_table = |sh: &gridcraft_model::Sheet| {
sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col)
};
if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) {
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
}
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
let id = d.wb.next_object_id();
edit(s, |cx| {
// Without a header row, insert one with generic names.
let r = if header {
r
} else {
let sh = cx.sheet_mut(sheet)?;
// The header row pushes the data down one row: never off the end of the sheet.
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
}
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
// reference (on any sheet) at the cells that moved.
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(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)));
}
cx.structural = true;
RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col))
};
let sh = cx.sheet_mut(sheet)?;
let mut names: Vec<String> = Vec::new();
// Lower-case names taken, and the next suffix for each header, so that thousands of equal
// headers don't take cubic time.
let mut taken = std::collections::HashSet::new();
let mut next_suffix = std::collections::HashMap::new();
for c in r.start.col..=r.end.col {
let mut n = sh.value(CellRef::new(r.start.row, c)).display();
if n.is_empty() {
n = format!("Column{}", c - r.start.col + 1);
}
let base = n.clone();
let key = base.to_ascii_lowercase();
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
while taken.contains(&n.to_ascii_lowercase()) {
n = format!("{base}{k}");
k += 1;
}
next_suffix.insert(key, k);
taken.insert(n.to_ascii_lowercase());
// Headers are text.
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str()));
names.push(n);
}
sh.tables.push(Table {
id,
name: name.clone(),
range: r,
header_row: true,
totals_row: false,
columns: names
.into_iter()
.map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
.collect(),
style,
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
table_type: None,
connection_id: None,
});
cx.structural = true;
Ok(json!({"table": name, "range": r.a1()}))
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
pub(super) fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
}
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let ti = sh
.tables
.iter()
.position(|t| t.range.contains(d.selection.active))
.ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?;
Ok((sheet, ti))
}
pub(super) fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let on = bool_param(p, "on");
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) };
match which {
"totals" => {
let v = on.unwrap_or(!t.totals_row);
if v != t.totals_row {
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
}
t.totals_row = v;
if v {
t.range.end.row += 1;
if let Some(first) = t.columns.first_mut() {
first.totals_label = Some("Total".into());
}
if t.columns.len() > 1
&& let Some(last) = t.columns.last_mut()
&& last.totals == TotalsFn::None
{
last.totals = TotalsFn::Sum;
}
} else {
let row = t.range.end.row;
t.range.end.row = t.range.end.row.saturating_sub(1);
let (c0, c1) = (t.range.start.col, t.range.end.col);
for c in c0..=c1 {
sh.remove_cell(CellRef::new(row, c));
}
}
write_totals(sh, ti);
cx.structural = true;
}
}
"bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows),
"bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols),
"firstCol" => t.first_col = on.unwrap_or(!t.first_col),
"lastCol" => t.last_col = on.unwrap_or(!t.last_col),
"header" => {
t.filter_button = on.unwrap_or(!t.filter_button);
if !t.filter_button {
t.filter.clear();
}
}
_ => {}
}
Ok(Json::Null)
})
}
/// Writes the totals row cells (labels and SUBTOTAL formulas).
fn write_totals(sh: &mut Sheet, ti: usize) {
let Some(t) = sh.tables.get(ti).cloned() else { return };
if !t.totals_row {
return;
}
let row = t.range.end.row;
for (i, col) in t.columns.iter().enumerate() {
let c = CellRef::new(row, t.range.start.col + i as u32);
if let Some(code) = col.totals.subtotal_code() {
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
} else if let Some(l) = &col.totals_label {
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
} else {
sh.remove_cell(c);
}
}
}
pub(super) fn table_total_fn(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string();
let f = match str_param(p, "function").unwrap_or("sum") {
"average" => TotalsFn::Average,
"count" => TotalsFn::Count,
"countNums" => TotalsFn::CountNums,
"max" => TotalsFn::Max,
"min" => TotalsFn::Min,
"stdDev" => TotalsFn::StdDev,
"var" => TotalsFn::Var,
"none" => TotalsFn::None,
_ => TotalsFn::Sum,
};
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
if let Some(t) = sh.tables.get_mut(ti)
&& let Some(i) = t.column_index(&col)
&& let Some(c) = t.columns.get_mut(i)
{
c.totals = f;
}
write_totals(sh, ti);
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
edit(s, |cx| {
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
t.style = style.clone();
}
Ok(Json::Null)
})
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
/// read as an operator inside a structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
name.chars().count() <= 255
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& !crate::cmd::formulas::is_r1c1(name)
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
pub(super) fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
let wb = &s.doc()?.wb;
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
edit(s, |cx| {
let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default();
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
t.name = name.clone();
}
// Structured references follow the rename.
for i in 0..cx.wb.sheets.len() {
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
let shadowed = cx.wb.name(&old, i).is_some();
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
for (c, f) in keys {
let Some(e) = f.expr() else { continue };
let ne = e.map(&mut |x| match x {
gridcraft_formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => {
st.table = name.clone();
gridcraft_formula::Expr::Struct(st)
}
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
o => o,
});
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
}
}
}
cx.structural = true;
Ok(Json::Null)
})
}
pub(super) fn table_convert(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ti) = find_table(s, p)?;
edit(s, |cx| {
// Apply the table look as plain formatting, then drop the table.
let wb = &mut cx.wb;
let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) };
let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::tables::table_cell_style(wb, si, &t, c))).collect();
for (c, st) in looks {
let id = wb.styles.intern(st);
if let Some(sh) = wb.sheet_mut(si) {
sh.set_style(c, id);
}
}
if let Some(sh) = wb.sheet_mut(si) {
sh.tables.remove(ti);
}
cx.structural = true;
Ok(Json::Null)
})
}
+2 -2
View File
@@ -3,7 +3,7 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use serde_json::{Value as Json, json};
use super::*;
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![
@@ -64,7 +64,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet,
"activeSheet": wb.active_sheet(),
"sheets": sheets,
"selection": d.selection.a1(),
"activeCell": d.selection.active.a1(),

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