Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 55850841d3 Release 0.14.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:30 -04:00
Brandon ThomasandClaude Opus 5.5 1f73f62c4f Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon Thomas fb1e59ff61 Merge remote-tracking branch 'origin/main' into land-1030b 2026-10-30 23:16:31 -04:00
Brandon ThomasandClaude Opus 5.5 8f59487cbe Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:39 -04:00
Brandon ThomasandClaude Opus 5.5 71d4e2311d Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:37 -04:00
Brandon Thomas 213fe1150e Review fixes: one Depth table for the BINOM, BETA and HARMEAN notes; summarise the function-parity header 2026-10-30 23:07:11 -04:00
Brandon Thomas 0db7d97f2e Merge PR #3008
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 4d1233d0a2 Merge PR #3007
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 07c8201786 Merge PR #3006
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 31d5cc5b41 Merge PR #3003
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas c0fcc7de16 Merge PR #3002
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 5390e230d8 Merge PR #3001
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas c9eb797d8b Merge PR #3000
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas 5441691ca3 Merge PR #2999
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:04:26 -04:00
Brandon Thomas d3f54debd9 Merge PR #2998
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:04:10 -04:00
Brandon Thomas 64ea0cb69c Merge PR #2997
# Conflicts:
#	crates/functions/src/text.rs
#	docs/function-parity.md
2026-10-30 23:04:01 -04:00
Brandon Thomas f887ecf1cf Merge PR #2996
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas ae598a74fe Merge PR #2995
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas bc0fffbe97 Merge PR #2994
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:40 -04:00
Brandon Thomas 1ebef5a694 Merge PR #2992
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:39 -04:00
Brandon Thomas f812c53814 Merge PR #2991
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas b775b3ce32 Merge PR #2990
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas 76aed738e3 Merge PR #2989 2026-10-30 23:03:37 -04:00
Brandon Thomas a44d3d5ee5 Merge PR #2982 2026-10-30 23:03:36 -04:00
Brandon Thomas 2bf5dbac84 Merge PR #2983 2026-10-30 23:03:36 -04:00
Brandon Thomas c269ff483d Merge PR #2993 2026-10-30 23:03:35 -04:00
Brandon ThomasandClaude Opus 5.5 15f6297001 Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:24 -04:00
Harrison Booth 006b7962bd Preserve F distribution tail accuracy 2026-10-30 21:14:19 -05:00
Harrison Booth 7ea3a4c2c3 Merge upstream main into harmonic mean branch 2026-10-30 20:59:56 -05:00
Harrison Booth cae0c9abdb Merge main into function updates 2026-10-30 20:59:41 -05:00
Harrison Booth ed81d7e34e Merge upstream main into binomial distribution branch 2026-10-30 20:59:39 -05:00
Harrison Booth 8f7dff0d04 Merge main into function updates 2026-10-30 20:59:31 -05:00
Harrison Booth 7b7e734524 Merge main into function updates 2026-10-30 20:59:20 -05:00
Harrison Booth 6d5ab57f84 Merge upstream main into Weibull distribution branch 2026-10-30 20:59:18 -05:00
Harrison Booth a04629576d Merge main into function updates 2026-10-30 20:59:12 -05:00
Harrison Booth 3174020a70 Merge main into function updates 2026-10-30 20:59:02 -05:00
Harrison Booth 781149a593 Merge main into function updates 2026-10-30 20:58:53 -05:00
Harrison Booth 322298b202 Merge upstream main into gamma distribution branch 2026-10-30 20:58:48 -05:00
Harrison Booth 11d6ff1b79 Merge main into function updates 2026-10-30 20:58:38 -05:00
Harrison Booth eefa7c4a54 Merge main into function updates 2026-10-30 20:58:24 -05:00
Harrison Booth 45939a8a41 Merge remote-tracking branch 'upstream/main' into gc-build-g718x
# Conflicts:
#	docs/function-parity.md
2026-10-30 20:56:57 -05:00
Harrison Booth 70da8bb3ae Cover strict upper tail in F inverse 2026-10-30 20:53:15 -05:00
Harrison Booth a0da53f576 Match T DIST zero degree errors by mode 2026-10-30 20:53:00 -05:00
Harrison Booth ca44a5d68f Avoid overflow in t density normalization 2026-10-30 20:52:05 -05:00
Harrison Booth 3b61d660e9 Return NUM for invalid T distribution degrees 2026-10-30 20:51:47 -05:00
Harrison Booth cca8a58bfa Stabilize F distribution CDF at extreme degrees 2026-10-30 20:51:07 -05:00
Harrison Booth d3cd393dba G719: return NA for empty harmonic mean input 2026-10-30 20:25:09 -05:00
Harrison Booth 8985685833 G718: match beta density endpoint errors 2026-10-30 20:24:21 -05:00
Harrison Booth 962a5df841 G717: cap binomial trial counts 2026-10-30 20:23:32 -05:00
Harrison Booth fea4c03ca2 G709: use stable tail at F median 2026-10-30 20:18:56 -05:00
Harrison Booth 1d9096e5ec G709: stabilize F upper tail at extreme degrees 2026-10-30 20:05:47 -05:00
Harrison Booth 8e740d97e1 G707: stabilize decimal boundary cases 2026-10-30 19:58:17 -05:00
Harrison Booth a9bacdca4c G711: keep T.DIST validation isolated 2026-10-30 19:56:36 -05:00
Cédric Legrand 53d6de3c16 docs(releasing): M13: correct outdated prerequisites 2026-10-31 01:49:44 +01:00
Harrison Booth 6aa2cb8223 G709: invert upper F probabilities through complementary tail 2026-10-30 19:49:32 -05:00
Harrison Booth 83e6408b71 G707: align signed output boundary regression 2026-10-30 19:47:30 -05:00
Harrison Booth 1b1224c67a G713: simplify zero-shape boundary 2026-10-30 19:42:50 -05:00
Harrison Booth 213b76af10 Expand bit shift boundary regressions 2026-10-30 19:39:26 -05:00
Harrison Booth 4e5c86d842 G711: preserve small density term at huge degrees 2026-10-30 19:39:05 -05:00
Harrison Booth ad69d4cbdd G710: update zero-degree test error 2026-10-30 19:39:00 -05:00
Harrison Booth 32dbf6a224 G705: fix single-character case conversions 2026-10-30 19:37:03 -05:00
Harrison Booth 11b54ebe40 G708: retain dollar sign when rounding to zero 2026-10-30 19:36:23 -05:00
Harrison Booth 9e710199b1 Move base source parity note to history 2026-10-30 19:36:06 -05:00
Harrison Booth 5ee65a8005 Tighten UNICHAR regressions and parity history 2026-10-30 19:35:50 -05:00
Harrison Booth 48c6b57060 G714: return zero Weibull density at zero 2026-10-30 19:34:59 -05:00
Harrison Booth ec2efe3246 G713: correct gamma density at zero 2026-10-30 19:34:36 -05:00
Harrison Booth 5ed6a1b9c7 G707: enforce fixed and dollar output limits 2026-10-30 19:34:32 -05:00
Harrison Booth 5d89b9efdf Move bit shift parity note to revision history 2026-10-30 19:34:19 -05:00
Harrison Booth f1ff38504a G712: return division error for zero T inverse probability 2026-10-30 19:34:12 -05:00
Harrison Booth 0435ff50f9 G711: stabilize large-degree t density 2026-10-30 19:33:50 -05:00
Harrison Booth 550085f8b8 G710: return division error for zero T degrees 2026-10-30 19:33:10 -05:00
Harrison Booth 3a27743a38 Move places parity note to revision history 2026-10-30 19:32:48 -05:00
Harrison Booth dc691d52db G709: accept F distribution boundary degrees 2026-10-30 19:32:22 -05:00
Harrison Booth bd8cf4e14a G706: avoid binary noise in fixed decimal text 2026-10-30 19:32:19 -05:00
Harrison Booth dc75555ee9 G705: match Excel single-character casing 2026-10-30 19:30:32 -05:00
Harrison Booth 4d725e6bc1 Reject Unicode noncharacters in UNICHAR 2026-10-30 19:24:46 -05:00
Harrison Booth 9fccec28af Handle zero-valued oversized bit shifts 2026-10-30 19:24:22 -05:00
Harrison Booth 52d317c8ff Validate places for negative base conversions 2026-10-30 19:23:51 -05:00
Harrison Booth 00359ee93a Merge current main into G701 branch 2026-10-30 19:23:09 -05:00
Harrison Booth c895acf1f7 Fix base conversion whitespace validation 2026-10-30 19:22:39 -05:00
Dylan M. Taylor 1f4c67ef0a fix(flatpak): allow reading launcher version pins (#2985)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:32 -04:00
Michael HatzandClaude Opus 5.5 8cf8425745 Print checkbox test: check that the unchecked box is outlined
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:52:22 +01:00
NetterSilverandClaude Haiku 5.5 59a8b14d4d Print structured refs to columns starting with @ in double brackets
Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 22:26:49 +00:00
Michael HatzandClaude Opus 5.5 8f93ba2e9c Print checkbox cells as boxes, not TRUE/FALSE (#2972)
A checkbox cell drew a box on screen but printed and exported to PDF
its value as TRUE or FALSE. The print path now draws the box as the
grid does: white with a grey outline when unchecked, green with a
white tick when checked. Other values in a checkbox cell still print
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:14:20 +01: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
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
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
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
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
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
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
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
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
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
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
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
Dmussi 848b0e658a Click a status bar statistic to copy its value 2026-10-25 23:24:26 -04:00
61 changed files with 1936 additions and 184 deletions
Generated
+16 -16
View File
@@ -1936,7 +1936,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"arboard",
"chrono",
@@ -1963,7 +1963,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1975,7 +1975,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1986,7 +1986,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1998,7 +1998,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"chrono",
"js-sys",
@@ -2009,7 +2009,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -2031,7 +2031,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"serde",
@@ -2040,7 +2040,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"regex",
@@ -2048,7 +2048,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-engine",
"log",
@@ -2057,7 +2057,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2069,18 +2069,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.13.0"
version = "0.14.2"
[[package]]
name = "gridcraft-ui-egui"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"craft-ui",
"craft-update",
@@ -2105,7 +2105,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"eframe",
"egui",
@@ -2122,7 +2122,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"aes",
"gridcraft-core",
@@ -6403,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-engine",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.13.0"
version = "0.14.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+3 -2
View File
@@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
// With `noopener` the new browsing context can't hold a reference back to the
// editor window and navigate it while the user reads the linked page.
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
// editor window and navigate it while the user reads the linked page. It must be a
// window *feature*: inside the target string it would only name the new window.
let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer");
}
})),
inbox: Some(inbox),
+164 -15
View File
@@ -12,16 +12,47 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::time::{Duration, Instant};
use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
/// How long a new connection may take to send its token.
/// How long a new connection may take to send its token, in total.
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
/// How many connections may be waiting to authenticate at once. Anyone on the machine can
/// connect without the token, so past this a new connection is closed before anything is read:
/// each waiting one holds a thread and a socket.
const MAX_PENDING_AUTH: usize = 16;
/// How the server treats connections that haven't authenticated yet.
#[derive(Clone, Copy)]
struct AuthLimits {
timeout: Duration,
max_pending: usize,
}
const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };
/// A connection that is still waiting to authenticate; dropping it frees its place.
struct Pending(Arc<AtomicUsize>);
impl Pending {
/// A place among the `max` waiting connections, or `None` when they are all taken.
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;
Some(Pending(Arc::clone(waiting)))
}
}
impl Drop for Pending {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
/// else 256 random bits from the operating system, written to a file only the user can read.
@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
}
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
let waiting = Arc::new(AtomicUsize::new(0));
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
for stream in listener.incoming() {
let Ok(stream) = stream else {
// Out of sockets or handles: wait for some to free instead of spinning on the error.
std::thread::sleep(Duration::from_millis(50));
continue;
};
// Dropping the stream closes it before anything is read.
let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
let _ =
std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));
}
})?;
Ok(rx)
}
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
let mut first = String::new();
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
let verdict = match read {
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
/// `timeout` in total) and check the token. `true` when the peer may go on.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {
let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
let mut first = Vec::new();
let read = read_auth_line(reader, &mut first, deadline);
let verdict = match (read, std::str::from_utf8(&first)) {
(Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token),
_ => Err(json!({"ok": false, "error": "authentication required"})),
};
let allowed = verdict.is_ok();
let reply = verdict.unwrap_or_else(|e| e);
let _ = writeln!(out, "{reply}");
let _ = out.set_read_timeout(None);
let _ = reader.get_ref().set_read_timeout(None);
allowed
}
/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.
///
/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps
/// its own, so one set on the writing half never fired (a silent peer held its thread forever).
/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {
let max = control_auth::MAX_AUTH_LINE;
while line.len() < max && line.last() != Some(&b'\n') {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::ErrorKind::TimedOut.into());
}
reader.get_ref().set_read_timeout(Some(left))?;
let buf = reader.fill_buf()?;
if buf.is_empty() {
break;
}
let room = max.saturating_sub(line.len());
let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);
line.extend(buf.iter().take(take));
reader.consume(take);
}
Ok(())
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
if !authenticate(&mut reader, &mut out, token) {
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
// Authenticated or refused, the connection no longer counts as waiting.
drop(place);
if !allowed {
return;
}
let mut buf = Vec::new();
@@ -294,7 +368,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
}
});
addr
@@ -346,6 +420,81 @@ mod tests {
assert_eq!(got[1]["ok"], json!(false));
}
/// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within
/// `within`, and how long it took.
#[allow(clippy::disallowed_methods)] // a native-only test of the desktop app
fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {
let start = Instant::now();
c.set_read_timeout(Some(within)).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
loop {
line.clear();
match r.read_line(&mut line) {
Ok(0) => return (true, start.elapsed()),
Ok(_) => continue,
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),
Err(_) => return (true, start.elapsed()),
}
}
}
/// The token deadline is for the whole first line: neither a silent peer nor one sending a
/// byte at a time keeps the connection (and its thread) past it.
#[test]
fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let silent = TcpStream::connect(addr).unwrap();
let mut slow = TcpStream::connect(addr).unwrap();
let mut drip = slow.try_clone().unwrap();
std::thread::spawn(move || {
for b in control_auth::auth_line(1, TOKEN).bytes() {
if drip.write_all(&[b]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
});
let (closed, took) = closed_within(silent, Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");
let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");
let _ = slow.flush();
}
/// Peers without the token can't pile up threads: past the cap a new connection is closed at
/// once, and a waiting one that authenticates frees its place.
#[test]
fn connections_waiting_to_authenticate_are_capped() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let mut first = TcpStream::connect(addr).unwrap();
let _second = TcpStream::connect(addr).unwrap();
std::thread::sleep(Duration::from_millis(200));
let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");
first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");
std::thread::sleep(Duration::from_millis(200));
let mut next = TcpStream::connect(addr).unwrap();
next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(next).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");
}
#[test]
fn a_chosen_token_must_be_long_enough() {
assert!(make_token(1, Some("short".into())).is_err());
+5 -4
View File
@@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> {
{
log::warn!("sample {s}: {e}");
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.start_update_check();
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
// Stamp the startup workbook's author from the persisted user name.
if app.session.documents().is_empty() {
app.session.new_workbook();
}
app.after_engine(); // Show warnings from the sample.
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
// opens them: a password-protected one asks for its password, and errors are shown, not
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
+8 -7
View File
@@ -390,11 +390,12 @@ impl NativeMenu {
#[cfg(test)]
mod tests {
use super::*;
// No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test
// harness, so the #[test] functions are left out and a module-level import would be unused.
#[test]
fn file_menu_has_export_as_pdf_dispatching_the_command() {
let tree = tree();
let tree = super::tree();
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
assert_eq!(item.1, "file.exportPdf");
@@ -403,23 +404,23 @@ mod tests {
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
let tree = tree();
let tree = super::tree();
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
assert_eq!(item.1, "app.checkForUpdates");
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
assert!(app.run(item.1, serde_json::json!({})).is_ok());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
for mode in ["dark", "system", "light"] {
assert!(select_theme(&mut app, &format!("theme:{mode}")));
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
assert_eq!(app.ui.theme_mode(), mode);
assert!(app.message.is_none());
}
assert!(!select_theme(&mut app, "view.formulaBar"));
assert!(!super::select_theme(&mut app, "view.formulaBar"));
assert_eq!(app.ui.theme_mode(), "light");
}
}
+13 -2
View File
@@ -19,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");
}
+3
View File
@@ -106,3 +106,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Plutonium-fream]
sync_github_name = true
+19
View File
@@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() {
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn lifted_lookups_stop_past_their_work_budget() {
// Security: each needle searched the whole lookup vector, so this one cell ran for hours
// (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now.
let mut t = T::new();
t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))");
assert_eq!(t.get("A1"), Value::Error(CellError::Num));
// Ordinary lifted lookups are unaffected.
for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() {
t.set(&format!("B{}", i + 1), &k.to_string());
t.set(&format!("C{}", i + 1), &(i + 1).to_string());
}
t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))");
t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))");
t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))");
t.set("D4", "=SUM(MATCH({25,50},B1:B5))");
assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
+6 -2
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
crate::io::csv_values_bytes(&wb)?
} else {
crate::io::save_bytes(&wb, &target)?
};
crate::io::write_file(&target, &bytes)?;
Ok(json!({"path": target, "bytes": bytes.len()}))
}
+36 -2
View File
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
Workbook, split_sheet,
};
// ---------------------------------------------------------------- drawing
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|| merges.iter().any(|m| m.start == *c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
}
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
draw_checkbox(pg, (x, y, w, h), b.k, on);
} else {
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
}
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
pg.restore();
}
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
/// print as text.
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
return None;
}
match v {
Value::Bool(on) => Some(*on),
Value::Empty => Some(false),
_ => None,
}
}
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
if s <= 0.0 {
return;
}
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
if on {
let green = [0x10, 0x7C, 0x41];
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
} else {
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
}
}
pub(super) struct PendingPdfLink {
pub(super) page: usize,
pub(super) sheet: usize,
+16
View File
@@ -565,3 +565,19 @@ fn rotated_wrapped_text_prints_rotated() {
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
}
#[test]
fn checkbox_cells_print_as_boxes_not_true_false() {
// #2972: a checkbox cell printed its value as TRUE or FALSE.
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
}
+2 -2
View File
@@ -298,7 +298,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
@@ -521,7 +521,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
};
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
let right = shift == "right";
+16 -1
View File
@@ -593,7 +593,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((v >= prev && v > s) || (v < prev && v < s))
&& ((v >= prev && round15(v) > s) || (v < prev && round15(v) < s))
{
break;
}
@@ -806,6 +806,21 @@ mod tests {
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
}
#[test]
fn series_keeps_value_equal_to_stop_after_float_drift() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "linear", "step": 0.1, "stop": 1.3})).unwrap();
s.execute("cell.set", serde_json::json!({"cell": "B1", "input": "0.1"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "B1:B4", "type": "growth", "step": 3, "stop": 0.3})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let col = |c: u32, n: u32| (0..n).map(|r| sh.value(CellRef::new(r, c))).collect::<Vec<_>>();
let n = Value::number;
assert_eq!(col(0, 6), vec![n(1.0), n(1.1), n(1.2), n(1.3), Value::Empty, Value::Empty]);
assert_eq!(col(1, 4), vec![n(0.1), n(0.3), Value::Empty, Value::Empty]);
}
#[test]
fn flash_programs() {
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
+33
View File
@@ -306,6 +306,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
.map_err(|e| EngineError::Other(e.to_string()))
}
/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
/// writes displayed text instead ([`save_bytes`]) and stays the default.
pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
gridcraft_core::Value::Number(n) if n.is_finite() => {
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
Some(((date, time), d)) => iso(&d, date, time),
None => n.to_string(),
}
}
gridcraft_core::Value::Text(t) => t.to_string(),
v => crate::display::value_text_as(wb, &v, "General"),
})
.map_err(|e| EngineError::Other(e.to_string()))
}
/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {
let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
if d.milli != 0 {
clock.push_str(&format!(".{:03}", d.milli));
}
match (date, time) {
(true, true) => format!("{day}T{clock}"),
(false, true) => clock,
_ => day,
}
}
pub(crate) fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+1
View File
@@ -19,6 +19,7 @@ fn copied(wb: &Workbook, range: &str) -> String {
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
#[test]
fn save_fails_upfront_when_the_temp_name_is_taken() {
// Another local user who can write the directory must not be able to point the
+1
View File
@@ -587,6 +587,7 @@ impl Session {
pub fn new_workbook(&mut self) -> usize {
let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1);
let mut wb = Workbook::new();
wb.props.author = self.prefs.user_name.clone();
for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) {
let name = wb.next_sheet_name();
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
+57
View File
@@ -2007,6 +2007,15 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() {
assert!(e.to_string().contains("pushed off the end"), "{e}");
}
#[test]
fn inserting_a_column_next_to_far_right_data_names_gridcraft_in_the_refusal() {
let mut sess = s();
sess.execute("cell.set", json!({"cell": "XFD1", "input": "2"})).unwrap();
let e = sess.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap_err();
assert!(e.to_string().contains("GridCraft cannot shift nonblank cells"), "{e}");
assert!(!e.to_string().contains("Excel"), "{e}");
}
#[test]
fn inserting_cells_refuses_to_push_values_off_the_sheet() {
for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] {
@@ -2015,6 +2024,7 @@ fn inserting_cells_refuses_to_push_values_off_the_sheet() {
sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap();
let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err();
assert!(e.to_string().contains("loss of data"), "{e}");
assert!(e.to_string().contains("GridCraft") && !e.to_string().contains("Excel"), "{e}");
assert_eq!(v(&sess, "A1"), Value::Number(1.0));
assert_eq!(v(&sess, edge), Value::Number(2.0));
// A value that still fits moves to the last valid cell.
@@ -2500,6 +2510,53 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
std::fs::remove_dir_all(dir).unwrap();
}
/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the
/// raw values for automation: full precision, ISO dates and times.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() {
let mut s = s();
for (cell, input, code) in [
("A1", "1234.5", "$#,##0.00"),
("B1", "0.123456789", "0.00"),
("C1", "46023", "d-mmm-yy"),
("D1", "46023.5", "yyyy-mm-dd hh:mm"),
("E1", "0.75", "h:mm"),
("F1", "TRUE", "General"),
("G1", "a,b", "General"),
("H1", "=1/0", "General"),
("I1", "1", "h:mm"),
("J1", "1.5", "h:mm"),
("K1", "46023", "yyyy-mm-dd hh:mm"),
("L1", "1.5", "[h]:mm"),
("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"),
] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap();
}
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let displayed = dir.join("displayed.csv");
s.execute("file.exportCsv", json!({"path": displayed})).unwrap();
s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap();
let raw = dir.join("raw.csv");
s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap();
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
// The default export is byte for byte what Save As CSV writes, and `values: false` is the default.
assert_eq!(std::fs::read(&displayed).unwrap(), default);
assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default);
let default = String::from_utf8(default).unwrap();
assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}");
assert_eq!(
std::fs::read_to_string(&raw).unwrap(),
"1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n"
);
std::fs::remove_dir_all(dir).unwrap();
}
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
fn linked_workbook(link: &str) -> Vec<u8> {
use std::io::{Cursor, Write};
+2
View File
@@ -23,9 +23,11 @@ mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod row_autofit;
mod sheet_copy_spill;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod table_resize_overlap;
mod table_total_row_last_row;
mod workbook_author;
mod wrap_row_height;
@@ -0,0 +1,33 @@
use gridcraft_core::{CellRef, Value};
use gridcraft_engine::Session;
use gridcraft_engine::calc::Calc;
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) {
s.execute(command, params).unwrap();
}
fn value(s: &Session, sheet: &str, cell: &str) -> Value {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap())
}
/// Issue #2053: the copy of a sheet holding a spill keeps the spill's values, in memory and after a full recalc.
#[test]
fn copied_sheet_keeps_spill_values_through_full_recalc() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"sample": "grades"}));
run(&mut s, "home.mergeCells", json!({"range": "F:H"}));
run(&mut s, "cell.set", json!({"cell": "J54", "input": "=SEQUENCE(5)*C6"}));
run(&mut s, "selection.set", json!({"range": "15:15"}));
run(&mut s, "sheet.move", json!({"copy": true, "sheet": 0, "to": 1}));
run(&mut s, "cell.set", json!({"array": true, "cell": "D3", "input": "=SUM('Grades (2)'!J54#)"}));
let before = value(&s, "Grades (2)", "J55");
assert!(matches!(before, Value::Number(_)), "J55 should hold a spilled number, got {before:?}");
let mut fresh = (*s.doc().unwrap().wb).clone();
Calc::new().recalc_all(&mut fresh);
let after = fresh.sheet(fresh.sheet_index("Grades (2)").unwrap()).unwrap().value(CellRef::parse("J55").unwrap());
assert_eq!(before, after);
}
+50
View File
@@ -0,0 +1,50 @@
//! Regression tests for the default author of newly created workbooks.
use gridcraft_engine::{DocState, Session, Trust, io, model::Workbook};
#[test]
fn new_workbook_stamps_the_configured_user_name_at_creation() {
let mut session = Session::new();
session.prefs.user_name = "Ada Lovelace".into();
let first = session.new_workbook();
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
// Changing the preference must not rewrite an existing workbook.
session.prefs.user_name = "Grace Hopper".into();
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
// A new workbook uses the preference as it exists at creation.
let second = session.new_workbook();
assert_eq!(session.documents()[second].wb.props.author, "Grace Hopper");
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
}
#[test]
fn opening_and_resaving_preserves_the_existing_workbook_author() {
// An existing XLSX has its own author metadata.
let mut source = Workbook::new();
source.props.author = "Original Spreadsheet Author".into();
let bytes = io::save_bytes(&source, "source.xlsx").unwrap();
let (opened, _) = io::open_bytes("source.xlsx", &bytes).unwrap();
let mut session = Session::new();
session.prefs.user_name = "Current GridCraft User".into();
session.add_document(DocState::new(opened, None, "source".into(), Trust::FromFile));
// Opening an existing workbook must not replace its author.
assert_eq!(session.doc().unwrap().wb.props.author, "Original Spreadsheet Author");
// The original author must survive another XLSX save and reopen.
let saved = io::save_bytes(&session.doc().unwrap().wb, "saved.xlsx").unwrap();
let (reopened, _) = io::open_bytes("saved.xlsx", &saved).unwrap();
assert_eq!(reopened.props.author, "Original Spreadsheet Author");
}
+2 -2
View File
@@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
}
(Some(c), None) if s.specifiers.is_empty() => {
// Excel writes a name with spaces at its edges in the double-bracket form.
if c.trim() == c {
// Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form.
if c.trim() == c && !c.starts_with('@') {
let _ = write!(out, "[{}]", esc(c));
} else {
let _ = write!(out, "[[{}]]", esc(c));
+9
View File
@@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box<dyn std::error::Error>>
}
Ok(())
}
#[test]
fn column_starting_with_at_round_trips() {
for name in ["@a", "a@b"] {
let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None };
let text = format!("={}", printer::struct_ref(&s));
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
}
}
+105 -14
View File
@@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R<String> {
Ok(number_to_text(n))
}
Value::Empty => Ok(String::new()),
v => Ok(v.to_text()?.trim().to_string()),
v => Ok(v.to_text()?.to_string()),
}
}
@@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
if v < min || v > max {
return Err(CellError::Num);
}
let places = places
.map(|p| {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
Ok(p as usize)
})
.transpose()?;
if v < 0 {
let u = v + (1i64 << bits);
return text_val(to_radix(u, radix));
@@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
match places {
None => text_val(s),
Some(p) => {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
let p = p as usize;
if s.len() > p {
return Err(CellError::Num);
}
@@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String {
}
fn places_arg(a: &[Arg]) -> R<Option<f64>> {
if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) }
if !has(a, 1) {
return Ok(None);
}
if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) {
return Err(CellError::Value);
}
Ok(Some(num(a, 1)?))
}
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
@@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn shift(a: &[Arg], left: bool) -> R<Value> {
let n = bit_operand(a, 0)?;
let s = num(a, 1)?.trunc();
if n == 0 {
return Ok(Value::Number(0.0));
}
if s.abs() > 53.0 {
return Err(CellError::Num);
}
@@ -248,11 +261,26 @@ im1!(imexp, |z: Cx| Ok(z.exp()));
im1!(imln, |z: Cx| z.ln());
im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10)));
im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2)));
// Excel takes the polar route, so IMSQRT("-4") is 1.22514845490862E-16+2i, not 2i. Its cosine
// reduces its argument with a 64-bit-mantissa pi, which is PI/2 + 2.5e-20 and shows up as the
// stray real part. `+ 0.0` turns a -0 imaginary part into +0, so the angle stays +pi.
// shortcut: parts above ~1E154 or below ~1E-155 keep the exact result (Excel gives #NUM! or 0), no known use.
const HALF_PI_EXCESS: f64 = 2.508_278_806_33e-20;
im1!(imsqrt, |z: Cx| {
let m = z.abs();
let re = ((m + z.re) / 2.0).sqrt();
let im = ((m - z.re) / 2.0).sqrt();
Ok(Cx::new(re, if z.im < 0.0 { -im } else { im }))
let im = z.im + 0.0;
let theta = match z.re {
0.0 => {
if im == 0.0 {
0.0
} else {
std::f64::consts::FRAC_PI_2.copysign(im)
}
}
x if x > 0.0 => (im / x).atan(),
x => (im / x).atan() + if im < 0.0 { -std::f64::consts::PI } else { std::f64::consts::PI },
};
let (half, root) = (theta / 2.0, z.abs().sqrt());
Ok(Cx::new(root * (half.cos() + HALF_PI_EXCESS * half.abs().sin()), root * half.sin()))
});
im1!(imsin, |z: Cx| Ok(z.sin()));
im1!(imcos, |z: Cx| Ok(z.cos()));
@@ -387,6 +415,12 @@ mod tests {
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num);
is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num);
is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num);
is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value);
is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111");
is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF");
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
@@ -411,6 +445,21 @@ mod tests {
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
for (f, input) in [
("BIN2DEC", " 10"),
("BIN2DEC", "10 "),
("BIN2DEC", "\t10"),
("BIN2HEX", " 10"),
("BIN2OCT", "10 "),
("HEX2DEC", " 10"),
("HEX2BIN", "10 "),
("HEX2OCT", "\t10"),
("OCT2DEC", " 10"),
("OCT2BIN", "10 "),
("OCT2HEX", "\t10"),
] {
is_err(ev(f, vec![t(input)]), CellError::Num);
}
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
@@ -431,6 +480,21 @@ mod tests {
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
for f in ["BITLSHIFT", "BITRSHIFT"] {
for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] {
close(ev(f, vec![n(0.0), n(shift)]), 0.0);
}
for shift in [-100.0, -54.0, 54.0, 100.0] {
is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num);
}
if f == "BITLSHIFT" {
is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num);
} else {
close(ev(f, vec![n(1.0), n(53.0)]), 0.0);
}
is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value);
}
is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num);
}
#[test]
@@ -509,9 +573,36 @@ mod tests {
cx(ev("IMLOG10", vec![t("3+4i")]), 0.698_970_004_336_018_8, 0.402_719_196_273_373_2);
cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8);
cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3);
cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0);
is_text(ev("IMSQRT", vec![t("-4")]), "2i");
is_text(ev("IMSQRT", vec![t("-9")]), "3i");
// Strings read back from Excel (16.0): its stray real part on negative reals included.
for (input, want) in [
("-4", "1.22514845490862E-16+2i"),
("-9", "1.83772268236293E-16+3i"),
("-1", "6.1257422745431E-17+i"),
("-2", "8.66310780426106E-17+1.4142135623731i"),
("-100", "6.1257422745431E-16+10i"),
("-1E10", "6.1257422745431E-12+100000i"),
("-1E-10", "6.1257422745431E-22+0.00001i"),
("-4+0j", "1.22514845490862E-16+2j"),
("-4-0i", "1.22514845490862E-16+2i"),
("-4-1E-20i", "1.22514845490862E-16-2i"),
("-1+1E-10i", "5.00000653944413E-11+i"),
("-0.5-1E-12i", "7.07212958930194E-13-0.707106781186548i"),
("4", "2"),
("2", "1.4142135623731"),
("0", "0"),
("3+4i", "2+i"),
("3+4j", "2+j"),
("-3+4i", "1+2i"),
("-3-4i", "1-2i"),
("-2i", "1-i"),
("2i", "1+i"),
("i", "0.707106781186548+0.707106781186547i"),
("5-12i", "3-2i"),
("-5+12i", "2+3i"),
("1E10+1E10i", "109868.411346781+45508.9860562227i"),
] {
is_text(ev("IMSQRT", vec![t(input)]), want);
}
is_text(ev("IMSQRT", vec![t("4")]), "2");
is_text(ev("IMSQRT", vec![t("-2i")]), "1-i");
is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i");
+8 -5
View File
@@ -311,11 +311,8 @@ fn hyperlink(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = a.get(1).map(scalar).unwrap_or(Value::Empty);
return Ok(empty_to_zero(v));
}
let link = a.first().map(scalar).unwrap_or(Value::Empty);
if let Value::Error(e) = link {
return Err(e);
}
Ok(link)
// Excel returns the link as text, so HYPERLINK(1) is "1" and ISTEXT is TRUE.
Ok(Value::Text(text(a, 0)?.into()))
}
fn encodeurl(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -608,6 +605,12 @@ mod tests {
is_text(ev("HYPERLINK", vec![t("http://x.com")]), "http://x.com");
is_text(ev("HYPERLINK", vec![t("http://x.com"), t("Click")]), "Click");
close(ev("HYPERLINK", vec![t("http://x.com"), n(5.0)]), 5.0);
// Excel 16: without a friendly name the result is the link as text.
is_text(ev("HYPERLINK", vec![n(1.0)]), "1");
is_text(ev("HYPERLINK", vec![n(1.5)]), "1.5");
is_text(ev("HYPERLINK", vec![b(true)]), "TRUE");
is_text(ev("HYPERLINK", vec![rf(Value::Empty)]), "");
is_err(ev("HYPERLINK", vec![e(CellError::Div0)]), CellError::Div0);
is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9");
is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value);
is_err(ev("FILTERXML", vec![t("<a/>"), t("//a")]), CellError::Value);
+61 -1
View File
@@ -58,6 +58,35 @@ fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
)
}
/// Lookups whose every call searches a lookup vector from its start, so a call lifted over `n`
/// needles does `n` x (vector length) work. Their search arguments don't lift.
const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XMATCH"];
/// Work budget of a lifted [`SCANS`] lookup: needle cells times the length of the longest vector
/// it searches (the longest dimension of its largest non-lifted array). Each needle re-reads and
/// scans the vector (about 8 ns per entry), so without a cap a one-cell formula such as
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file.
/// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst
/// case to a few seconds and still admits 5,000 needles over a 100,000-row table.
const LIFTED_SCAN_BUDGET: usize = 500_000_000;
/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec`
/// is one of the [`SCANS`] lookups and a non-lifted argument is an array.
fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize {
if !SCANS.contains(&spec.name) {
return 0;
}
args.iter()
.enumerate()
.filter(|(i, _)| !spec.is_scalar_arg(*i))
.filter_map(|(_, a)| match &a.value {
Value::Array(arr) => Some(arr.rows.max(arr.cols)),
_ => None,
})
.max()
.unwrap_or(0)
}
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
let args = rejected.as_deref().unwrap_or(args);
@@ -76,7 +105,8 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val
if !any {
return (spec.imp)(args, ctx);
}
if rows.saturating_mul(cols) > MAX_CELLS {
let needles = rows.saturating_mul(cols);
if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET {
return Value::Error(CellError::Num);
}
let mut cell_args: Vec<Arg> = args.to_vec();
@@ -149,6 +179,36 @@ mod tests {
is_err(huge, CellError::Num);
}
/// Security: a lifted lookup searches its whole lookup vector once per needle, so needles x
/// vector length past `LIFTED_SCAN_BUDGET` is `#NUM!` at once.
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` used to run for hours (and hung opening
/// the 1.6 KB file holding it).
#[test]
fn lifted_lookups_stop_past_their_work_budget() {
use gridcraft_core::Array;
let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into());
let wide = |n: usize| av(Array::filled(1, n, nv(-1.0)).into());
is_err(ev("XMATCH", vec![tall(1_048_576), tall(1_048_576)]), CellError::Num);
// Just past the budget, for every lookup: 501 needles over 1,000,000 entries.
assert_eq!(super::LIFTED_SCAN_BUDGET, 500 * 1_000_000);
is_err(ev("XMATCH", vec![tall(501), tall(1_000_000)]), CellError::Num);
is_err(ev("MATCH", vec![tall(501), tall(1_000_000), n(0.0)]), CellError::Num);
is_err(ev("XLOOKUP", vec![tall(501), tall(1_000_000), tall(1_000_000)]), CellError::Num);
is_err(ev("VLOOKUP", vec![tall(501), tall(1_000_000), n(1.0), b(false)]), CellError::Num);
is_err(ev("HLOOKUP", vec![tall(501), wide(1_000_000), n(1.0), b(false)]), CellError::Num);
is_err(ev("LOOKUP", vec![tall(501), tall(1_000_000)]), CellError::Num);
// Within the budget lifted lookups work as before.
let keys = || av(col(&[10.0, 20.0, 30.0]));
let pos = ev("XMATCH", vec![av(col(&[30.0, 10.0, 99.0])), keys()]);
assert_eq!(rows_of(&pos), vec![vec![nv(3.0)], vec![nv(1.0)], vec![Value::Error(CellError::NA)]]);
let found = ev("XLOOKUP", vec![av(row(&[20.0, 30.0])), keys(), av(col(&[1.0, 2.0, 3.0]))]);
assert_eq!(rows_of(&found), vec![vec![nv(2.0), nv(3.0)]]);
// Other lifted functions aren't metered: INDEX reads one entry per position.
let picks: Vec<f64> = (1..=1000u32).map(f64::from).collect();
let out = ev("INDEX", vec![tall(1_048_576), av(col(&picks))]);
assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]);
}
#[test]
fn value_params_reject_multi_cell_references() {
let s = FnSpec { value_args: &[true, false], ..spec() };
+10
View File
@@ -694,6 +694,16 @@ pub(crate) fn specs() -> Vec<FnSpec> {
f!("ROUNDDOWN", 2, 2, MathTrig, S, "ROUNDDOWN(number, num_digits)", "Rounds a number toward zero to a given number of digits.", rounddown),
f!("MROUND", 2, 2, MathTrig, S, "MROUND(number, multiple)", "Rounds a number to the nearest multiple.", mround),
f!("CEILING", 2, 2, Compatibility, S, "CEILING(number, significance)", "Rounds a number up to a multiple of significance.", ceiling),
f!(
"ECMA.CEILING",
2,
2,
Compatibility,
S,
"ECMA.CEILING(number, significance)",
"Rounds a number up to a multiple of significance.",
ceiling
),
f!(
"CEILING.MATH",
1,
+5
View File
@@ -101,6 +101,11 @@ fn rounding() {
close(ev2("CEILING", 1.5, 0.1), 1.5);
close(ev2("CEILING", 0.234, 0.01), 0.24);
is_err(ev2("CEILING", 2.5, -1.0), Num);
// ECMA.CEILING is CEILING under a second name (Excel table from #2776).
close(ev2("ECMA.CEILING", 1.5, 1.0), 2.0);
close(ev2("ECMA.CEILING", -2.5, -2.0), -4.0);
close(ev2("ECMA.CEILING", 2.5, 0.0), 0.0);
is_err(ev2("ECMA.CEILING", 1.5, -1.0), Num);
close(ev2("FLOOR", 3.7, 2.0), 2.0);
close(ev2("FLOOR", -2.5, -2.0), -2.0);
close(ev2("FLOOR", -2.5, 2.0), -4.0);
+4 -1
View File
@@ -420,7 +420,10 @@ pub(super) fn geomean(v: &[f64]) -> R<f64> {
}
pub(super) fn harmean(v: &[f64]) -> R<f64> {
if v.is_empty() || v.iter().any(|&x| x <= 0.0) {
if v.is_empty() {
return Err(CellError::NA);
}
if v.iter().any(|&x| x <= 0.0) {
return Err(CellError::Num);
}
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
+40 -14
View File
@@ -22,7 +22,14 @@ fn t_cdf(t: f64, v: f64) -> f64 {
}
fn t_pdf(t: f64, v: f64) -> f64 {
(ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp()
let z = v / 2.0;
let log_gamma_ratio = if z >= 16.0 {
let inv_z = 1.0 / z;
0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0
} else {
ln_gamma(z + 0.5) - ln_gamma(z)
};
(log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp()
}
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
@@ -51,6 +58,12 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x == 1.0 && d1 == d2 {
return 0.5;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
}
@@ -58,6 +71,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 {
return 1.0;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
// With a much larger numerator degree, the scaled beta tail converges to a gamma tail.
return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
}
@@ -74,6 +91,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
}
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
if k < 0.0 || k > n {
return 0.0;
@@ -176,8 +195,14 @@ fn df_arg(a: &[Arg], i: usize) -> R<f64> {
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
let x = num(a, 0)?;
let v = df_arg(a, 1)?;
let v = num(a, 1)?.trunc();
let cum = boolean(a, 2)?;
if v == 0.0 {
return Err(if cum { CellError::Num } else { CellError::Div0 });
}
if v < 0.0 {
return Err(CellError::Num);
}
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
}
@@ -209,6 +234,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R<Value> {
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 0)?;
let v = df_arg(a, 1)?;
if p == 0.0 {
return Err(CellError::Div0);
}
if !(p > 0.0 && p < 1.0) {
return Err(CellError::Num);
}
@@ -347,7 +375,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> {
let x = num(a, 0)?;
let d1 = num(a, 1)?.trunc();
let d2 = num(a, 2)?.trunc();
if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 {
if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 {
return Err(CellError::Num);
}
Ok((x, d1, d2))
@@ -378,7 +406,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R<Value> {
if p == 0.0 {
return num_val(0.0);
}
num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60))
let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) };
num_val(value)
}
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
@@ -410,7 +439,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R<Value> {
let n = num(a, 1)?.trunc();
let p = num(a, 2)?;
let cum = boolean(a, 3)?;
if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) {
if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) {
return Err(CellError::Num);
}
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
@@ -421,7 +450,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 1)?;
let s1 = num(a, 2)?.trunc();
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
return Err(CellError::Num);
}
if s2 - s1 > 10_000.0 {
@@ -499,13 +528,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R<Value> {
return num_val(reg_gamma_p(al, x / be));
}
if x == 0.0 {
return if al < 1.0 {
Err(CellError::Num)
} else if al == 1.0 {
num_val(1.0 / be)
} else {
num_val(0.0)
};
return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) };
}
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
}
@@ -548,7 +571,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R<Value> {
if cum {
return num_val(reg_inc_beta(z, al, be));
}
if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) {
if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) {
return Err(CellError::Num);
}
let lp = if z == 0.0 || z == 1.0 {
@@ -608,6 +631,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R<Value> {
if x < 0.0 || al <= 0.0 || be <= 0.0 {
return Err(CellError::Num);
}
if x == 0.0 {
return num_val(0.0);
}
let t = (x / be).powf(al);
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
}
+102
View File
@@ -151,6 +151,19 @@ fn spread() {
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
let text_values = arr(vec![vec![tv("1"), tv("2")]]);
let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]);
let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]);
is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA);
close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0);
close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0);
close(ev("HARMEAN", vec![b(true)]), 1.0);
close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num);
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
@@ -360,6 +373,7 @@ fn normal() {
#[test]
fn t_chi_f() {
close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10);
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
@@ -397,6 +411,7 @@ fn t_chi_f() {
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
@@ -404,6 +419,48 @@ fn t_chi_f() {
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
}
#[test]
fn f_distributions_accept_ten_billion_degrees() {
close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0);
close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28);
close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0);
close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8);
close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0);
is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num);
is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num);
}
#[test]
fn t_dist_zero_degrees_error_depends_on_mode() {
for df in [0.0, 0.9, -0.1] {
is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0);
is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num);
}
is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num);
close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
}
#[test]
fn t_density_is_stable_for_large_degrees() {
close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12);
close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12);
close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12);
}
#[test]
fn t_inv_zero_probability_returns_div0() {
is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0);
close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7);
is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num);
is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num);
}
#[test]
fn binom_inv_huge_trials_returns() {
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
@@ -427,7 +484,15 @@ fn discrete() {
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
@@ -466,6 +531,19 @@ fn continuous() {
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num);
close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0);
is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num);
close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25);
close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0);
close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0);
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
@@ -479,6 +557,30 @@ fn continuous() {
close_tol(r[0][1].clone(), 0.841344746, 1e-8);
}
#[test]
fn gamma_density_at_zero_shape_one_is_num() {
for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] {
is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num);
}
is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num);
}
#[test]
fn weibull_density_at_zero_is_zero() {
for shape in [0.5, 1.0, 2.0] {
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0);
}
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15);
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num);
}
#[test]
fn aggregate_helper() {
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
+113 -12
View File
@@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(text(a, 0)?.chars().count() as f64)
}
fn lower_char(ch: char) -> char {
match ch.to_lowercase().next() {
Some(lower) => lower,
None => ch,
}
}
fn upper_char(ch: char) -> char {
let mut uppercase = ch.to_uppercase();
match uppercase.next() {
Some(upper) if uppercase.next().is_none() => upper,
_ => ch,
}
}
fn proper_upper_char(ch: char) -> char {
if ch == '\u{130}' { 'I' } else { upper_char(ch) }
}
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_lowercase())
text_val(text(a, 0)?.chars().map(lower_char).collect::<String>())
}
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_uppercase())
text_val(text(a, 0)?.chars().map(upper_char).collect::<String>())
}
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
for ch in s.chars() {
if ch.is_alphabetic() {
if prev_letter {
out.extend(ch.to_lowercase());
out.push(lower_char(ch));
} else {
out.extend(ch.to_uppercase());
out.push(proper_upper_char(ch));
}
prev_letter = true;
} else {
@@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
if !(1.0..=1_114_111.0).contains(&n) {
return Err(CellError::Value);
}
match char::from_u32(n as u32) {
let cp = n as u32;
if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE {
return Err(CellError::NA);
}
match char::from_u32(cp) {
Some(c) => text_val(c.to_string()),
None => Err(CellError::NA),
}
@@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
let d = decimals.max(0) as usize;
let s = format!("{:.*}", d, x.abs());
let s = x.abs().to_string();
let (int_part, frac) = match s.split_once('.') {
Some((i, f)) => (i.to_string(), Some(f.to_string())),
None => (s.clone(), None),
Some((i, f)) => (i, f),
None => (s.as_str(), ""),
};
let int_part = if commas {
let b = int_part.as_bytes();
@@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
}
o
} else {
int_part
int_part.to_string()
};
let mut out = int_part;
if let Some(f) = frac {
if d > 0 {
let frac_len = frac.len().min(d);
out.push('.');
out.push_str(&f);
out.push_str(&frac[..frac_len]);
out.extend(std::iter::repeat_n('0', d - frac_len));
}
out
}
@@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?;
let no_commas = opt_bool(a, 2, false)?;
let body = format_fixed(r, d, !no_commas);
if body.len() + if r < 0.0 { 1 } else { 0 } > 255 {
return Err(CellError::Value);
}
text_val(if r < 0.0 { format!("-{body}") } else { body })
}
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?;
let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0);
let body = format_fixed(r, d, true);
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") })
let out = if negative { format!("(${body})") } else { format!("${body}") };
if out.len() > 254 {
return Err(CellError::Value);
}
text_val(out)
}
// ---------------------------------------------------------------------------------------------
@@ -1123,6 +1156,20 @@ mod tests {
fn case_and_cleanup() {
is_text(ev("UPPER", vec![t("abc")]), "ABC");
is_text(ev("LOWER", vec![t("ABC")]), "abc");
is_text(ev("UPPER", vec![t("Straße")]), "STRAßE");
is_text(ev("LOWER", vec![t("İ")]), "i");
is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul");
is_text(ev("PROPER", vec![t("ß")]), "ß");
is_text(ev("PROPER", vec![t("ÉCOLE")]), "École");
let proper = ev("PROPER", vec![t("İSTANBUL Straße")]);
is_text(proper.clone(), "Istanbul Straße");
assert_eq!(
match proper {
Value::Text(s) => s.chars().count(),
_ => 0,
},
"Istanbul Straße".chars().count()
);
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
@@ -1181,8 +1228,15 @@ mod tests {
close(ev("CODE", vec![t("€")]), 128.0);
is_err(ev("CODE", vec![t("")]), CellError::Value);
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] {
is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA);
}
for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] {
is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected);
}
close(ev("UNICODE", vec![t("☃")]), 9731.0);
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
}
@@ -1237,10 +1291,57 @@ mod tests {
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
let fixed_16 = format!("1.1{}", "0".repeat(15));
let dollar_16 = format!("$1.1{}", "0".repeat(15));
let fixed_20 = format!("1.005{}", "0".repeat(17));
let dollar_20 = format!("$1.005{}", "0".repeat(17));
let fixed_127 = format!("1.1{}", "0".repeat(126));
let dollar_127 = format!("$1.1{}", "0".repeat(126));
is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16);
is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16);
is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20);
is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20);
is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127);
is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127);
is_text(ev("T", vec![t("x")]), "x");
is_text(ev("T", vec![n(1.0)]), "");
}
#[test]
fn fixed_and_dollar_output_limits() {
assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-')));
is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value);
is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value);
}
#[test]
fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() {
is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)");
is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)");
is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)");
is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0");
}
#[test]
fn before_after() {
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
+13 -1
View File
@@ -15,7 +15,7 @@ use gridcraft_core::{CellError, DateSystem, Value};
pub use builtin::{builtin_format, builtin_id};
pub use decimal::format_general_fit;
use parse::{SecKind, Section, Tok};
use parse::{DatePart, SecKind, Section, Tok};
use render::Out;
/// Width (in characters) of General output in a cell.
@@ -193,6 +193,18 @@ impl NumberFormat {
self.num_sections().iter().any(|s| s.kind == SecKind::Date)
}
/// For a date or time format, whether the section that formats `value` shows calendar parts (year, month,
/// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for
/// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment.
pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> {
let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?;
let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None });
if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) {
return None;
}
Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar())))
}
pub fn is_percent(&self) -> bool {
self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0)
}
+9
View File
@@ -55,6 +55,15 @@ impl SheetApp {
// Requests the command made (a dialog, a draft) belong to the window it left active.
self.follow_active_window();
self.after_engine();
if id == "formulas.autoSum"
&& let Some(ed) = self.editor.as_mut()
&& ed.text.starts_with('=')
&& ed.text.ends_with("()")
{
ed.caret = ed.text.chars().count().saturating_sub(1);
ed.anchor = ed.caret;
ed.sync_caret = true;
}
if id == "view.arrangeAll"
&& self.native_windows
&& let Ok(r) = &r
+17 -16
View File
@@ -264,6 +264,23 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
if objects_hidden(wb) {
return painted;
}
// Source outlines stay in cell space beneath every floating object.
if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id))
&& anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect())
{
for s in &ch.series {
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
{
let rr = geo.range_rect(sh, rf.range());
let color = Tokens::get(p.ctx()).ref_color(0, true);
for pane in geo.range_panes(rf.range()) {
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
}
}
}
}
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
// The grid paints the active workbook: its pictures are cached under it.
let doc = app.session.active().map_or(0, |d| d.uid);
@@ -365,23 +382,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
if app.selected_chart == Some(ch.id) {
selection_frame(p, r);
}
// The source-data highlight below is in cell space: mirrored with the cells.
done(start, r);
if app.selected_chart == Some(ch.id) {
// Highlight the source data like Excel does.
for s in &ch.series {
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
{
let rr = geo.range_rect(sh, rf.range());
let color = Tokens::get(p.ctx()).ref_color(0, true);
for pane in geo.range_panes(rf.range()) {
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
}
}
}
}
}
paint_slicers(app, p, geo, wb, si, sh);
painted
+31 -11
View File
@@ -72,6 +72,8 @@ pub struct Dialog {
focus_initialized: bool,
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
pick: Option<RefPick>,
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
original_link_text: Option<String>,
}
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
@@ -165,6 +167,7 @@ impl Dialog {
self.values.insert(pick.key.into(), json!(ed.text));
}
/// Creates a command-backed form with its initial field values.
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
@@ -183,9 +186,11 @@ impl Dialog {
back: None,
focus_initialized: false,
pick: None,
original_link_text: None,
}
}
/// Creates a dialog whose body handles its own controls instead of a standard form.
pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
@@ -204,9 +209,11 @@ impl Dialog {
back: None,
focus_initialized: false,
pick: None,
original_link_text: None,
}
}
/// Opens the named dialog with defaults from the active workbook, including a cell's stored hyperlink.
pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option<Dialog> {
let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default();
let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default();
@@ -494,17 +501,30 @@ impl Dialog {
json!({"url": url}),
)
}
"insertLink" => Dialog::form(
"insertLink",
"Insert Hyperlink",
"insert.link",
vec![
Text { key: "text", label: "Display:" },
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
Text { key: "tooltip", label: "ScreenTip:" },
],
json!({"target": "https://"}),
),
"insertLink" => {
let mut d = Dialog::form(
"insertLink",
"Insert Hyperlink",
"insert.link",
vec![
Text { key: "text", label: "Display:" },
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
Text { key: "tooltip", label: "ScreenTip:" },
],
json!({"target": "https://"}),
);
if let Some(doc) = app.session.active()
&& let Some(sheet) = doc.wb.active()
&& let Some(link) = sheet.hyperlinks.get(&doc.selection.active)
{
let text = gridcraft_engine::display::cell_text(&doc.wb, sheet, doc.selection.active);
d.values.insert("target".into(), json!(link.target));
d.values.insert("text".into(), json!(text));
d.values.insert("tooltip".into(), json!(link.tooltip));
d.original_link_text = Some(text);
}
d
}
"sparkline" => Dialog::form(
"sparkline",
"Create Sparklines",
+8
View File
@@ -15,9 +15,17 @@ use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2};
/// Builds the command params from form values for dialogs that need translation.
/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog.
/// Unchanged hyperlink display text is omitted so its number or formula stays intact.
pub(crate) fn build_params(d: &Dialog) -> Json {
let mut v = Json::Object(d.values.clone());
match d.name.as_str() {
"insertLink" => {
if d.original_link_text.as_deref().is_some_and(|text| d.values.get("text").and_then(Json::as_str) == Some(text))
&& let Some(m) = v.as_object_mut()
{
m.remove("text");
}
}
"advancedFilter" => {
if let Some(m) = v.as_object_mut() {
m.remove("action");
+24 -5
View File
@@ -518,9 +518,23 @@ fn editor_arrow_keys(
ed: &mut crate::editor::EditState,
mut action: Option<EditorAction>,
) -> (Option<EditorAction>, bool) {
let (up, down, left, right, shift) = ui.input(|i| {
(i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowLeft), i.key_pressed(Key::ArrowRight), i.modifiers.shift)
// The modifiers come with the key event: an arrow pressed and released within one frame (a
// control client's or an automation tool's keystroke) has already lost them from `i.modifiers`.
let arrow = ui.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key: key @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => {
Some((*key, *modifiers))
}
_ => None,
})
});
let (up, down, left, right) = (
arrow.is_some_and(|(k, _)| k == Key::ArrowUp),
arrow.is_some_and(|(k, _)| k == Key::ArrowDown),
arrow.is_some_and(|(k, _)| k == Key::ArrowLeft),
arrow.is_some_and(|(k, _)| k == Key::ArrowRight),
);
let (shift, jump) = arrow.map_or((false, false), |(_, m)| (m.shift, m.command));
if (up || down) && !ed.autocomplete.is_empty() {
ui.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, Key::ArrowUp);
@@ -545,13 +559,18 @@ fn editor_arrow_keys(
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let dc = if rtl { -dc } else { dc };
if ed.can_point() {
// Ctrl+arrow (⌘arrow) jumps the pointed cell to the edge of its data, Ctrl+Shift+arrow
// extends the pointed range there, as the same keys do on the selection.
ui.input_mut(|i| {
for key in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] {
i.consume_key(egui::Modifiers::NONE, key);
i.consume_key(egui::Modifiers::SHIFT, key);
for modifiers in
[egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT]
{
i.consume_key(modifiers, key);
}
}
});
crate::grid::point_move(app, ed, dr, dc, shift);
crate::grid::point_move(app, ed, dr, dc, shift, jump);
(action, false)
} else {
action = Some((dr, dc, false, false));
+5 -3
View File
@@ -1014,11 +1014,13 @@ pub fn follow_link(app: &mut SheetApp, target: &str) {
}
}
/// Used by `EditState` users to keep the active cell visible while pointing.
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) {
/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region;
/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible.
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) {
let Some(d) = app.session.active() else { return };
let Some(sheet) = d.wb.active() else { return };
let from = ed.point_cell.unwrap_or(ed.cell);
let to = from.offset_clamped(dr, dc);
let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) };
let anchor = if extend {
// The anchor is the corner opposite the moving end: the written range is normalized, so
// after extending up or left its first cell is the moving end, not the anchor.
+37 -3
View File
@@ -5,8 +5,8 @@ use super::cell_paint::{
use super::geo::Geo;
use super::tables::{filter_button_rect, paint_filter_button};
use super::{
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, Value,
Workbook, pos2, theme, vec2,
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, VAlign,
Value, Workbook, pos2, theme, vec2,
};
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
@@ -68,6 +68,30 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
}
}
/// Restrict top-aligned wrapped text to complete lines inside the cell.
///
/// Preserve the cell and viewport clip when every line fits. Otherwise,
/// exclude overflowing lines while retaining the visible fitting lines.
fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect {
let mut clip = rect.intersect(p.clip_rect());
if valign != VAlign::Top {
return clip;
}
let Some(last_row) = galley.rows.last() else {
return clip;
};
if y + last_row.rect().bottom() <= rect.bottom() + 0.01 {
return clip;
}
let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01);
clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top());
clip
}
pub(crate) struct QuadrantPaint<'a> {
pub(crate) painter: &'a Painter,
pub(crate) geo: &'a Geo,
@@ -80,6 +104,11 @@ pub(crate) struct QuadrantPaint<'a> {
pub(crate) tokens: &'a Tokens,
}
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
///
/// For top-aligned wrapped text, painting is limited to complete lines
/// fitting within the cell height while respecting viewport clipping.
/// Other vertical alignment modes retain their existing behavior.
pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
let (r0, r1) = quadrant.rows;
let (c0, c1) = quadrant.cols;
@@ -288,6 +317,10 @@ fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col:
filter_space == 0.0 && (overflows || across_end > col) && !in_merge
}
/// Paint one worksheet cell using its computed appearance and visible geometry.
///
/// Handles text layout, wrapping, alignment, overflow into neighbouring cells,
/// and clipping, while preserving cell decoration and merged-cell constraints.
fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLook>), merges: &[RangeRef]) {
let (cell, rect, lk, cfl) = painted;
let c = *cell;
@@ -363,7 +396,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
_ => text_left + pad + indent,
};
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color);
return;
}
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
+2 -1
View File
@@ -61,7 +61,8 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
}
},
// Insert works on one area at a time, so several areas still go through the dialog.
Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) {
// The numpad's + arrives as Plus without Shift; Excel takes Ctrl with it as well.
Key::Plus | Key::Equals if shift || key == Key::Plus => match whole_lines(app).filter(|_| single_area(app)) {
Some(true) => Some(("home.insertRows", json!({}))),
Some(false) => Some(("home.insertColumns", json!({}))),
None => {
+13 -1
View File
@@ -413,8 +413,20 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
}
ui.add_space(16.0);
// The layout runs right to left, so the last statistic is drawn first.
// Clicking a statistic copies its value (without the label) to the clipboard.
for p in selection_statistics(app).into_iter().rev() {
text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
let value = p.split_once(": ").map_or(p.as_str(), |(_, v)| v).to_owned();
let resp = ui.add(
egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim))
.selectable(false)
.sense(Sense::click()),
);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(app.ui.language.tr("Click to copy"));
if resp.clicked() {
ui.ctx().copy_text(value);
}
// The label senses clicks, so it also has to open the statistics menu on right-click.
resp.context_menu(|ui| status_bar_menu(app, ui));
ui.add_space(10.0);
}
});
@@ -0,0 +1,157 @@
//! #2861: a selected chart's source outline belongs behind floating objects, in cell space.
use egui::{Color32, Rect, Shape, pos2, vec2};
use gridcraft_engine::core::RangeRef;
use gridcraft_engine::model::Anchor;
use gridcraft_ui_egui::{SheetApp, theme::Tokens};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
const SOURCE: &str = "B2:B12";
const SHAPE_FILL: Color32 = Color32::from_rgb(0x12, 0x34, 0x56);
const CELL_FILL: Color32 = Color32::from_rgb(0xEF, 0xDC, 0xBA);
/// The source range crosses a shape and its selected chart, while its top remains uncovered.
fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) {
let mut session = crate::support::blank_session();
let values: Vec<_> = (1..=11).map(|value| json!([value])).collect();
session.execute("range.setValues", json!({"range": "B2", "values": values})).unwrap();
session.execute("home.fillColor", json!({"range": SOURCE, "color": "#EFDCBA"})).unwrap();
session.execute("selection.set", json!({"range": SOURCE})).unwrap();
let chart = session.execute("insert.chart", json!({"type": "column", "at": "B7", "width": 260, "height": 130})).unwrap();
let id = chart["chart"].as_u64().unwrap() as u32;
session.execute("insert.shape", json!({"kind": "rectangle", "at": "B4", "width": 160, "height": 35, "fill": "123456"})).unwrap();
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
session.execute("view.rightToLeft", json!({"on": rtl})).unwrap();
if frozen {
session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap();
}
let mut h = crate::support::harness(session, 4);
h.state_mut().selected_chart = Some(id);
if frozen {
h.state_mut().view_mut().unwrap().scroll.y = 20.0;
}
h.run_steps(3);
(h, id)
}
/// The exact on-screen source rectangle and its pane clips, including RTL and frozen rows.
fn source_geometry(h: &Harness) -> (Rect, Vec<Rect>) {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = app.grid.geo.as_ref().unwrap();
let range = RangeRef::parse(SOURCE).unwrap();
let rect = geo.mirror_rect(geo.range_rect(sheet, range));
let clips = geo.range_panes(range).into_iter().map(|pane| geo.mirror_rect(pane.intersect(geo.cells))).collect();
(rect, clips)
}
/// Allows only floating-point rounding when RTL translation and reflection reach the same bounds.
fn same_rect(a: Rect, b: Rect) -> bool {
a.min.distance(b.min) < 0.01 && a.max.distance(b.max) < 0.01
}
/// The last frame's actual source-outline paint commands, with their order and visible clip.
fn source_strokes(h: &Harness) -> Vec<(usize, Rect)> {
let (source, _) = source_geometry(h);
let color = Tokens::get(&h.ctx).ref_color(0, true);
h.output()
.shapes
.iter()
.enumerate()
.filter_map(|(index, shape)| match &shape.shape {
Shape::Rect(rect) if same_rect(rect.rect, source) && rect.stroke.color == color && rect.stroke.width == 1.5 => {
Some((index, shape.clip_rect))
}
_ => None,
})
.collect()
}
/// Floating objects move as a whole on RTL sheets, so their on-screen bounds remain unflipped.
fn object_rect(h: &Harness, anchor: &Anchor) -> Rect {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = app.grid.geo.as_ref().unwrap();
geo.mirror_rect(Rect::from_min_size(
pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z),
vec2(anchor.width * geo.z, anchor.height * geo.z),
))
}
/// Finds an opaque object fill in final paint order, rather than relying on implementation calls.
fn fill_index(h: &Harness, bounds: Rect, color: Color32) -> usize {
h.output()
.shapes
.iter()
.position(|shape| matches!(&shape.shape, Shape::Rect(rect) if same_rect(rect.rect, bounds) && rect.fill == color))
.expect("the overlapping floating object's fill was painted")
}
/// Opaque objects must cover the overlapping source stroke while the uncovered stroke stays visible.
#[test]
fn chart_source_outlines_stay_below_every_overlapping_object() {
for rtl in [false, true] {
for frozen in [false, true] {
let (h, id) = fixture(rtl, frozen);
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
let chart = sheet.charts.iter().find(|chart| chart.id == id).unwrap();
let chart_bounds = object_rect(&h, &chart.anchor);
let shape_bounds = object_rect(&h, &sheet.shapes[0].anchor);
let chart_fill = fill_index(&h, chart_bounds, Tokens::get(&h.ctx).grid_bg);
let shape_fill = fill_index(&h, shape_bounds, SHAPE_FILL);
let (source, expected_clips) = source_geometry(&h);
let strokes = source_strokes(&h);
assert_eq!(strokes.len(), expected_clips.len(), "source remains painted: rtl={rtl}, frozen={frozen}");
for ((_, clip), expected) in strokes.iter().zip(expected_clips) {
assert_eq!(*clip, expected, "source clips stay in cell space");
}
let visible_top = source.top().max(strokes[0].1.top());
let x = if rtl { source.left() + 0.75 } else { source.right() - 0.75 };
let uncovered = pos2(x, visible_top + 4.0);
assert!(source.contains(uncovered) && strokes[0].1.contains(uncovered));
assert!(!chart_bounds.contains(uncovered) && !shape_bounds.contains(uncovered), "uncovered source outline remains visible");
let background = h
.output()
.shapes
.iter()
.enumerate()
.find_map(|(index, shape)| match &shape.shape {
Shape::Rect(rect) if rect.fill == CELL_FILL && rect.rect.contains(uncovered) && shape.clip_rect.contains(uncovered) => {
Some(index)
}
_ => None,
})
.expect("the uncovered source cell has an opaque fill");
assert!(strokes[0].0 > background, "source outline remains above cell backgrounds");
for (name, bounds, fill) in [("chart", chart_bounds, chart_fill), ("shape", shape_bounds, shape_fill)] {
let covered = pos2(x, bounds.center().y);
assert!(source.contains(covered) && bounds.contains(covered), "fixture overlaps the {name}'s source stroke");
for (stroke, _) in &strokes {
assert!(*stroke < fill, "source outline paints over {name}: rtl={rtl}, frozen={frozen}, stroke={stroke}, fill={fill}");
}
}
}
}
}
/// Moving the source pass must preserve deselection, hidden-object and offscreen-chart behavior.
#[test]
fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() {
for state in ["unselected", "hidden", "offscreen"] {
let (mut h, id) = fixture(false, false);
assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source");
match state {
"unselected" => h.state_mut().selected_chart = None,
"hidden" => {
h.state_mut().run("view.showObjects", json!({"on": false})).unwrap();
}
_ => {
h.state_mut().run("object.move", json!({"kind": "chart", "id": id, "at": "B100"})).unwrap();
}
}
h.run_steps(3);
assert!(source_strokes(&h).is_empty(), "{state} chart must not highlight its source");
}
}
@@ -140,6 +140,15 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() {
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1,2)");
}
#[test]
fn empty_cell_autosum_places_caret_inside_parentheses() {
let mut app = app();
app.run("formulas.autoSum", json!({"enter": false})).unwrap();
let ed = app.editor.as_ref().unwrap();
assert_eq!(ed.text, "=SUM()");
assert_eq!(ed.caret, ed.text.chars().count() - 1);
}
#[test]
/// Insert Function and AutoSum use `=SUMA(`, while user-supplied formula text is not translated twice.
fn canonical_engine_templates_enter_the_selected_formula_language() {
+176
View File
@@ -0,0 +1,176 @@
//! #2876: editing a stored hyperlink starts from its address, display text and ScreenTip.
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::core::CellRef;
use gridcraft_engine::model::{Cell, Hyperlink};
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::control::{Outcome, handle};
use serde_json::{Value as Json, json};
type Harness = egui_kittest::Harness<'static, SheetApp>;
/// Runs the same dialog controls used by the public automation interface.
fn control(h: &mut Harness, method: &str, params: Json) -> Json {
let ctx = h.ctx.clone();
let Outcome::Done(result) = handle(h.state_mut(), &ctx, method, &params) else { panic!("unexpected screenshot response") };
assert_eq!(result["ok"], true, "{method}: {result}");
result
}
/// A non-default active cell carrying a stored hyperlink and the requested input.
fn linked_harness(input: &str, target: &str) -> Harness {
let mut session = crate::support::blank_session();
session.execute("cell.set", json!({"cell": "B2", "input": input})).unwrap();
session.execute("insert.link", json!({"cell": "B2", "target": target, "tooltip": "Original tip"})).unwrap();
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
crate::support::harness(session, 4)
}
/// Captures both content and stored link metadata so cancellation and undo are exact.
fn linked_cell(h: &Harness) -> (Cell, Hyperlink) {
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
let at = CellRef::parse("B2").unwrap();
(sheet.cells.get(at).cloned().unwrap(), sheet.hyperlinks.get(&at).cloned().unwrap())
}
/// Both relationship URLs and workbook locations prefill the same form.
#[test]
fn existing_external_and_internal_links_prefill_their_fields() {
for target in ["https://example.org/original?q=1", "Sheet1!F9"] {
let mut h = linked_harness("Original label", target);
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
h.run_steps(3);
let dialog = h.state().dialog.as_ref().unwrap();
assert_eq!(dialog.values["target"], target);
assert_eq!(dialog.values["text"], "Original label");
assert_eq!(dialog.values["tooltip"], "Original tip");
assert!(h.query_by_label("Display:").is_some());
assert!(h.query_by_label("ScreenTip:").is_some());
}
}
/// The keyboard entry point shares the prefilled form rather than resetting the address.
#[test]
fn ctrl_k_opens_the_existing_link_for_editing() {
let mut h = linked_harness("Original label", "https://example.org/original");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K);
h.run_steps(3);
let dialog = h.state().dialog.as_ref().unwrap();
assert_eq!(dialog.name, "insertLink");
assert_eq!(dialog.values["target"], "https://example.org/original");
}
/// Both the OK button and automation confirmation update one link and undo in one step.
#[test]
fn editing_a_link_confirms_and_undo_restores_it() {
for button in [false, true] {
let mut h = linked_harness("Original label", "https://example.org/original");
let before = linked_cell(&h);
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "Sheet1!F9"}));
control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Updated label"}));
control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Updated tip"}));
if button {
h.run_steps(4);
h.get_by_label(" OK ").click();
h.run_steps(4);
} else {
control(&mut h, "ui.dialog.confirm", json!({}));
}
assert!(h.state().dialog.is_none());
let (cell, link) = linked_cell(&h);
assert_eq!(cell.value.display(), "Updated label");
assert_eq!(link.target, "Sheet1!F9");
assert_eq!(link.tooltip.as_deref(), Some("Updated tip"));
assert!(!link.external);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(linked_cell(&h), before);
}
}
/// Closing a partially edited form leaves the existing link and cell untouched.
#[test]
fn cancelling_link_edits_keeps_the_original_cell_and_link() {
let mut h = linked_harness("Original label", "Sheet1!F9");
let before = linked_cell(&h);
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/changed"}));
control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Changed label"}));
control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Changed tip"}));
control(&mut h, "ui.dialog.cancel", json!({}));
assert!(h.state().dialog.is_none());
assert_eq!(linked_cell(&h), before);
}
/// Rendered forms keep linked numbers, formulas and number formats when their display text is unchanged.
#[test]
fn unchanged_display_text_preserves_number_and_formula_cells() {
for (input, format, expected) in [("42", "General", "42"), ("=6*7", "General", "42"), ("42", "0.00", "42.00"), ("=6*7", "0.00", "42.00")] {
let mut h = linked_harness(input, "https://example.org/original");
h.state_mut().run("home.numberFormat", json!({"code": format})).unwrap();
let (before, _) = linked_cell(&h);
let doc = h.state().session.active().unwrap();
let display = gridcraft_engine::display::cell_text(&doc.wb, doc.wb.active().unwrap(), CellRef::parse("B2").unwrap());
assert_eq!(display, expected, "formatted input {input}");
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().unwrap().values["text"], display);
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"}));
h.run_steps(3);
control(&mut h, "ui.dialog.confirm", json!({}));
let (after, link) = linked_cell(&h);
assert_eq!(after, before, "input {input}");
assert_eq!(link.target, "https://example.org/updated");
assert_eq!(link.tooltip.as_deref(), Some("Original tip"));
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
h.run_steps(3);
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert_eq!(linked_cell(&h).0, before, "unedited OK for {input}");
}
}
/// A missing ScreenTip stays absent when only the address changes or OK is pressed unchanged.
#[test]
fn editing_a_link_without_a_tooltip_keeps_it_absent() {
let mut h = linked_harness("Original label", "https://example.org/original");
h.state_mut().run("insert.link", json!({"target": "https://example.org/original"})).unwrap();
assert_eq!(linked_cell(&h).1.tooltip, None);
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
h.run_steps(3);
let dialog = h.state().dialog.as_ref().unwrap();
assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), "");
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"}));
h.run_steps(3);
control(&mut h, "ui.dialog.confirm", json!({}));
let (cell, link) = linked_cell(&h);
assert_eq!(cell.value.display(), "Original label");
assert_eq!(link.target, "https://example.org/updated");
assert_eq!(link.tooltip, None);
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
h.run_steps(3);
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert_eq!(linked_cell(&h).1.tooltip, None);
}
/// A new link still begins with the web-address default and uses its target as a blank cell's label.
#[test]
fn new_links_keep_the_existing_defaults() {
let mut h = crate::support::blank_harness(4);
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
h.run_steps(3);
let dialog = h.state().dialog.as_ref().unwrap();
assert_eq!(dialog.values["target"], "https://");
assert_eq!(dialog.values.get("text").and_then(Json::as_str).unwrap_or_default(), "");
assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), "");
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/new"}));
h.run_steps(3);
control(&mut h, "ui.dialog.confirm", json!({}));
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
let at = CellRef::parse("A1").unwrap();
assert_eq!(sheet.value(at).display(), "https://example.org/new");
assert_eq!(sheet.hyperlinks[&at].target, "https://example.org/new");
}
+37
View File
@@ -331,6 +331,43 @@ fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() {
assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor");
}
#[test]
fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() {
// Typing `=COUNTIF(`, clicking the top of a column and pressing Ctrl+Shift+Down (⌘⇧↓) points
// at the whole column of data, as the same keys select it outside an edit.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1:B5", "values": [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]})).unwrap();
s.execute("selection.set", json!({"cell": "D1"})).unwrap();
let mut h = harness(s);
text(&mut h, "=COUNTIF(");
click_cell(&mut h, "A1");
let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone());
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1"));
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT));
key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:A5"), "Ctrl+Shift+Down extends to the last filled cell");
key(&mut h, Key::ArrowRight, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:B5"), "Ctrl+Shift+Right extends the moving end across the data");
key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A:B"), "past the data, Ctrl+Shift+Down reaches the sheet edge (written as whole columns)");
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
key(&mut h, Key::ArrowUp, Modifiers::COMMAND);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B5"), "Ctrl+Up without Shift points at the next filled cell");
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
key(&mut h, Key::ArrowUp, Modifiers::NONE);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell");
// An automation tool (osascript `key code`) presses and releases within one frame, so by the
// time the frame runs `i.modifiers` is already empty: the modifiers that count are the ones on
// the press event.
h.input_mut().events.extend([
Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND },
Event::ModifiersChanged(Modifiers::NONE),
Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
h.run_steps(2);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B1"), "Command released in the same frame as the press still jumps");
}
#[test]
fn shortcuts_format_and_extend() {
let mut s = blank();
+2
View File
@@ -15,6 +15,7 @@ mod cell_editor_caret;
mod cell_pictures;
mod cf_rule_pick;
mod chart_resize;
mod chart_source_layering;
mod clipboard;
mod comments_pane;
mod commit_after_pointing;
@@ -51,6 +52,7 @@ mod header_autofit;
mod header_column_drag;
mod host_chrome;
mod hyperlink_ctrl_click;
mod hyperlink_dialog;
mod in_cell_editor_size;
mod in_cell_editor_wrap;
mod insert_copied_cells;
+283 -1
View File
@@ -12,11 +12,15 @@ const LEFT: &str = "This is a very long left-aligned text that overflows into ma
const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells";
fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
harness_with_size(egui::vec2(1200.0, 800.0), setup)
}
fn harness_with_size(size: egui::Vec2, setup: impl FnOnce(&mut Session)) -> Harness {
let mut s = Session::new();
s.new_workbook();
setup(&mut s);
s.execute("selection.set", json!({"range": "A20"})).unwrap();
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
let mut h = crate::common::builder().with_size(size).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
@@ -108,3 +112,281 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() {
assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}");
assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}");
}
/// Fully fitting wrapped lines retain the whole cell clip, including
/// room for glyphs extending below their logical line boxes.
#[test]
fn wrapped_text_fitting_lines_keep_full_cell_clip() {
const TEXT: &str = "Short wrapped text with two or three lines.";
let h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 15})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 120})).unwrap();
});
let cell = cell_rect(&h, "B3");
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected one wrapped text shape");
let (text, clip) = shapes[0];
assert!(text.galley.rows.len() >= 2, "text must wrap");
assert!(text.galley.size().y < cell.height(), "all lines must fit");
let last_bottom = text.pos.y + text.galley.rows.last().unwrap().rect().bottom();
assert!(last_bottom < cell.bottom() - 1.0, "room must remain below the final line");
assert!((clip.bottom() - cell.bottom()).abs() <= 0.5, "full cell clip must be retained: clip={clip:?}, cell={cell:?}");
}
/// Issue #2752: fixed-height wrapped cells must not paint
/// a fraction of a text line against the next row's border.
#[test]
fn wrapped_text_in_fixed_height_row_shows_only_complete_lines() {
const TEXT: &str = "Row 3 target. This is intentionally long text that must wrap \
across many lines within a narrow column. It must never \
paint over the next row.";
let h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
// Apply the custom height after Wrap Text so AutoFit
// cannot expand the row for this scenario.
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 62.67})).unwrap();
});
let cell = cell_rect(&h, "B3");
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected one painted wrapped text shape");
let (text, clip) = shapes[0];
assert!(text.galley.rows.len() > 4, "test must produce multiple wrapped lines");
assert!(text.galley.size().y > cell.height(), "wrapped text must exceed the fixed row height");
assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must stay within B3: {clip:?} vs {cell:?}");
let mut full_rows_visible = 0;
for (index, row) in text.galley.rows.iter().enumerate() {
let top = text.pos.y + row.rect().top();
let bottom = text.pos.y + row.rect().bottom();
let full_height = bottom - top;
let visible_height = (bottom.min(clip.bottom()) - top.max(clip.top())).max(0.0);
let partly_visible = visible_height > 0.5 && visible_height < full_height - 0.5;
assert!(
!partly_visible,
"wrapped line {} is partially visible: \
line=[{top:.2}, {bottom:.2}], clip={clip:?}",
index + 1
);
if visible_height >= full_height - 0.5 {
full_rows_visible += 1;
}
}
assert!(full_rows_visible >= 2, "the clipping fix must still show complete text lines");
}
/// Issue #2752: a row shorter than a text line must not
/// render a fragment of wrapped text.
#[test]
fn wrapped_text_in_tiny_row_has_no_partial_lines() {
const TEXT: &str = "This wrapped text should not be partly painted.";
let h = harness(|s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 5})).unwrap();
});
let cell = cell_rect(&h, "B3");
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected the wrapped text shape");
let (text, clip) = shapes[0];
assert!(text.galley.rows.len() > 1, "text must wrap into multiple lines");
assert!(cell.height() < text.galley.rows[0].rect().height(), "test setup must produce a row shorter than one line");
assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must remain inside its cell");
assert!(clip.height() <= 0.5, "no partial line should be visible: clip={clip:?}");
}
/// Issue #2752: the viewport bottom can cut through a line
/// that otherwise fits fully inside a wrapped cell.
#[test]
fn wrapped_text_clipping_survives_partial_vertical_scroll() {
let text = "Wrapped text must stay within both the worksheet cell \
and the viewport during vertical scrolling. "
.repeat(40);
let mut h = harness_with_size(egui::vec2(1200.0, 520.0), |s| {
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": text})).unwrap();
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 350})).unwrap();
});
let initial_cell = cell_rect(&h, "B3");
let (target_center, zoom) = {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == text => Some(t),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "expected wrapped B3 text");
let painted = shapes[0];
let center = painted
.galley
.rows
.iter()
.map(|row| {
let top = painted.pos.y + row.rect().top();
let bottom = painted.pos.y + row.rect().bottom();
(top, bottom)
})
.find(|(top, bottom)| {
let center = (top + bottom) / 2.0;
center > geo.cells.bottom() + 32.0 && *bottom < initial_cell.bottom() - 1.0
})
.map(|(top, bottom)| (top + bottom) / 2.0)
.expect("fixture must provide a cell-fitting line below the viewport");
(center, geo.z)
};
let viewport_bottom_before_scroll = {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
geo.cells.bottom()
};
{
let app = h.state_mut();
app.view_mut().unwrap().scroll.y = (target_center - viewport_bottom_before_scroll) / zoom;
}
h.run_steps(4);
let cell = cell_rect(&h, "B3");
let (viewport, scroll_zoom) = {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
(geo.cells, geo.z)
};
assert!(scroll_zoom > 0.0);
let shapes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(t) if t.galley.job.text == text => Some((t, shape.clip_rect)),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "B3 must remain painted");
let (painted, clip) = shapes[0];
assert!(clip.top() >= viewport.top() - 0.5, "clip exceeds viewport top: {clip:?} vs {viewport:?}");
assert!(clip.bottom() <= viewport.bottom() + 0.5, "clip exceeds viewport bottom: {clip:?} vs {viewport:?}");
assert!(clip.bottom() <= cell.bottom() + 0.5, "clip exceeds cell bottom: {clip:?} vs {cell:?}");
let full_line_bottoms: Vec<f32> =
painted.galley.rows.iter().map(|row| painted.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect();
let cell_line_bottom = full_line_bottoms.last().copied().expect("at least one line must fit within B3");
assert!(
painted.galley.rows.iter().any(|row| {
let top = painted.pos.y + row.rect().top();
let bottom = painted.pos.y + row.rect().bottom();
top + 0.5 < viewport.bottom() && viewport.bottom() < bottom - 0.5 && bottom <= cell.bottom() + 0.01
}),
"viewport bottom must cross a line that fits B3: \
viewport={viewport:?}, cell={cell:?}"
);
let expected_bottom = viewport.bottom().min(cell_line_bottom);
assert!(
(clip.bottom() - expected_bottom).abs() <= 0.5,
"clip must respect viewport and complete-line limits: \
clip={clip:?}, expected={expected_bottom:.2}"
);
}
@@ -1,4 +1,4 @@
//! Ctrl+Shift+= and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel;
//! Ctrl+Shift+= (or Ctrl with the numpad's +) and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel;
//! any other selection opens the Insert or Delete dialog.
use egui::{Key, Modifiers};
@@ -57,6 +57,22 @@ fn whole_columns_are_inserted_and_deleted_at_once() {
assert_eq!(value(&h, "A1"), Value::from("a1"));
}
#[test]
fn numpad_plus_inserts_without_shift() {
let mut h = harness("2:2");
press(&mut h, Key::Plus, false);
assert!(h.state().dialog.is_none(), "no dialog for whole rows");
assert!(value(&h, "A2").is_empty(), "a row was inserted above row 2");
assert_eq!(value(&h, "A3"), Value::from("a2"));
}
#[test]
fn unshifted_equals_inserts_nothing() {
let mut h = harness("2:2");
press(&mut h, Key::Equals, false);
assert_eq!(value(&h, "A2"), Value::from("a2"));
}
#[test]
fn other_selections_still_ask_which_way_to_shift() {
let mut h = harness("A2:B2");
+22 -5
View File
@@ -169,6 +169,16 @@ impl Hash {
}
}
/// The digest's length in bytes.
fn digest_len(self) -> usize {
match self {
Hash::Sha1 => 20,
Hash::Sha256 => 32,
Hash::Sha384 => 48,
Hash::Sha512 => 64,
}
}
/// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64.
fn block_size(self) -> usize {
match self {
@@ -444,6 +454,8 @@ fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
})
}
/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the
/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments.
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
let descriptor = agile_descriptor(xml)?;
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor;
@@ -464,15 +476,20 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptE
let secret = field(&key_value, &BLOCK_KEY)?;
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
// Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the
// package key (the verifier above only authenticates the password), keyed by a random
// salt-size array, computed over the whole EncryptedPackage stream, StreamSize field included.
// package key (the verifier above only authenticates the password), computed over the whole
// EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a
// random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so
// both lengths are tried.
let mut mac_field = hmac_key;
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?;
mac_field.truncate(key_data.salt.len());
let mut declared = hmac_value;
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?;
let mac = hmac(key_data.hash, &mac_field, package);
if declared.get(..mac.len()) != Some(mac.as_slice()) {
let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| {
let Some(key) = mac_field.get(..len) else { return false };
let mac = hmac(key_data.hash, key, package);
declared.get(..mac.len()) == Some(mac.as_slice())
});
if !verified {
return Err(malformed("the decrypted data failed its integrity check"));
}
let (size, data) = package_parts(package)?;
+18 -1
View File
@@ -3,7 +3,7 @@
//! files are built in code by `crate::testing`. No Excel-produced file is used.
use super::*;
use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header};
use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, encrypt_standard, with_agile_header};
const PASSWORD: &str = "Pass1234";
/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values
@@ -231,3 +231,20 @@ fn integrity_matches_the_stream_not_the_plaintext() {
assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
}
#[test]
fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() {
// Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size
// key. Both verify, and neither lets a modified package through.
let plain = payload(5000);
for key in [vec![7u8; 64], vec![7u8; 16]] {
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key);
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len());
let mut tampered = package.clone();
tampered[100] ^= 1;
assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
}
// Another length (a key that matches neither) fails.
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]);
assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
}
+14 -3
View File
@@ -21,7 +21,13 @@ const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
const SECRET_KEY: [u8; 32] =
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127];
/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the
/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes.
const HMAC_KEY: [u8; 64] = [
112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140,
141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
170, 171, 172, 173, 174, 175,
];
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
/// (AES-256, SHA-512) inside a compound file.
@@ -237,6 +243,11 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the
/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it.
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY)
}
/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`.
pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (Vec<u8>, Vec<u8>) {
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
let mut body = vec![];
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
@@ -244,8 +255,8 @@ pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>
}
let stream = package(plain.len(), body);
let (hmac_key_b64, hmac_value_b64) = (
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), &HMAC_SALT)),
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &stream))),
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)),
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))),
);
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
}
+3 -1
View File
@@ -1,6 +1,6 @@
# Chart parity
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's chart types and chart elements against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
@@ -38,6 +38,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
| Element | Excel | GridCraft |
|---|---|---|
| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) |
| Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) |
| Chart title, axis titles | text, linked to cells, full formatting | text only |
| Legend | position, overlay, formatting, per-entry delete | position only |
| Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag |
@@ -90,6 +91,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) |
| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` |
| 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) |
| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) |
+2 -1
View File
@@ -23,7 +23,8 @@ The control port is reachable by every program on the machine, and by a web page
`{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing
token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or
one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the
connection is closed. After that, a line that isn't JSON also closes the connection.
connection is closed. At most 16 connections may be waiting to send their token at once; past that a new
connection is closed before anything is read. After that, a line that isn't JSON also closes the connection.
- `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use
`GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same
user on the same machine without setup). Your own client reads the file or the variable.
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -66,9 +66,9 @@ values) is still dropped.
| Format | Excel | GridCraft read | GridCraft write | Notes |
|---|---|---|---|---|
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns |
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns |
| `.prn` formatted text (space-delimited) | R/W | no | no | |
| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy |
@@ -115,6 +115,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
| 2026-10-20 | trivial | A workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) |
| 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection |
| 2026-10-26 | trivial | `file.exportCsv {values: true}` writes raw cell values (full-precision numbers, ISO 8601 dates and times) for automation; the default stays Excel's displayed text (#2648) |
| 2026-10-19 | minor | CSV and text import decodes likely Windows-1256 Arabic; other invalid UTF-8 retains the Windows-1252 fallback |
| 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets |
| 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |
+29 -3
View File
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -20,8 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function
evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE,
OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL,
MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name.
- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR),
is in our library as a Compatibility function for files that use it. It is not counted above.
- **Not on Microsoft's list:** USDOLLAR and ECMA.CEILING, legacy functions Excel still evaluates (like DOLLAR),
are in our library as Compatibility functions for files that use them. They are not counted above.
- **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no
cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks
WEBSERVICE and FILTERXML; Excel for Windows has them.
@@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened
| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) |
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
| Localized function names and `;` separators | **missing** (#45); en-US only |
| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them |
@@ -97,6 +98,12 @@ This improves series preparation; it does not change the model-fitting or season
## Depth (estimated)
| Function | Excel parity note |
|---|---|
| BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. |
| BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter is less than or equal to 1; cumulative endpoint results remain 0 and 1. |
| HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. |
**~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many
assertions each, but there is no conformance suite against Excel-computed results, and user
reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range
@@ -123,6 +130,25 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid |
| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel |
| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel |
| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel |
| 2026-10-31 | minor | UPPER, LOWER and PROPER use single-character casing: `UPPER("Straße")` keeps ß, `LOWER("İ")` returns i, and `PROPER("İSTANBUL")` returns Istanbul, matching Windows Excel 16.0 build 20527 in LCID 1033 |
| 2026-10-31 | minor | FIXED and DOLLAR format the rounded number's shortest decimal representation before padding requested fractional zeroes, so 1.1 at 16 places and 1.005 at 20 places do not expose binary noise; measured in Windows Excel 16.0 build 20527, LCID 1033 |
| 2026-10-31 | minor | FIXED accepts output through 255 characters; DOLLAR accepts output through 254. Windows Excel 16.0 build 20527 (LCID 1033) confirmed the distinct cutoffs with grouped and ungrouped values, signs, currency layout and decimal places |
| 2026-10-31 | minor | DOLLAR uses the input sign for negative amounts that round to zero at nonnegative decimal places (`DOLLAR(-0.0001,0)` is `($0)`); when negative decimal places round to zero, Excel returns `$0`. Exact zero and negative zero stay unsigned; FIXED still returns `0` for `FIXED(-0.0001,0)` |
| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail, with a gamma-tail limit when numerator degrees dominate, to preserve inverse accuracy; values above the limit remain invalid |
| 2026-10-31 | minor | T.DIST returns `#DIV/0!` in density mode and `#NUM!` in cumulative mode when degrees truncate to zero; negative integer degrees remain `#NUM!` |
| 2026-10-31 | minor | Student-t density uses an asymptotic log-gamma ratio for large degrees of freedom, avoiding cancellation while preserving bounded work; checked against Excel at 100 million and 10 billion degrees |
| 2026-10-31 | minor | T.INV probability zero returns `#DIV/0!`, while other out-of-range probabilities and invalid degrees remain `#NUM!`; T.INV.2T keeps its separate boundary behavior |
| 2026-10-31 | minor | GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` for all positive scales; the cumulative branch and other shape boundaries are unchanged |
| 2026-10-31 | minor | WEIBULL.DIST returns zero at x=0 in both density and cumulative modes, including shape below one; negative x remains `#NUM!` |
| 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation |
| 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha less than or equal to 1 and upper endpoint with beta less than or equal to 1; cumulative behavior is unchanged |
| 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` |
| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change |
| 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open |
| 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` |
| 2026-10-23 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 |
| 2026-10-23 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns |
+19 -19
View File
@@ -19,25 +19,24 @@ This is GridCraft's implementation of the shared [release playbook](release-play
| Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` |
| App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `<branding>` |
## Prerequisites still owned by the app crates
## Application release prerequisites
The pipeline references crates that don't exist yet; the first release run fails until they do:
The `apps/gridcraft` and `apps/gridcraft-web` crates are workspace members, and
`apps/gridcraft-cli` supports `--version`. The `cargo xtask` alias is defined in
`.cargo/config.toml`. The current app-specific release setup includes:
- **`apps/gridcraft`** (the GUI binary `gridcraft`) and **`apps/gridcraft-web`** (trunk
site) must be workspace members. `apps/gridcraft-cli` exists and supports `--version`.
- **Windows resources:** move `packaging/windows/app-build.rs.in` to `apps/gridcraft/build.rs`
and add `winresource = "0.1"` under `[build-dependencies]` (instructions at the top of the
file). `package.ps1` sets `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon
fails, and it checks that `gridcraft.exe` is a GUI-subsystem binary, so `main.rs` needs
`#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]`.
- **Build info:** CI exports `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE`; read them
with `option_env!` (fallback: "dev build") for `--version` and *Help › About GridCraft*.
- **Runtime icon:** embed `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
- **Windows resources:** `apps/gridcraft/build.rs` embeds the icon and version information with
`winresource` (instructions at the top of the file). `package.ps1` sets
`GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon fails, and it checks that
`gridcraft.exe` is a GUI-subsystem binary. `apps/gridcraft/src/main.rs` sets
`windows_subsystem = "windows"` for release builds.
- **Version information:** `gridcraft --version`, `gridcraft-cli --version` and the About dialog
use `env!("CARGO_PKG_VERSION")` from `[workspace.package]`. `packaging/env.sh` also exports
`GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE` for artifact metadata.
- **Runtime icon:** `apps/gridcraft/src/main.rs` embeds
`assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
`hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and
use `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
- **`cargo xtask` alias:** the docs use `cargo xtask …`, which needs
`.cargo/config.toml` with `[alias] xtask = "run --package xtask --"`. Until it exists, use
`cargo run -p xtask -- …`. The workflows already call `cargo run -p xtask` directly.
uses `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
## Cutting a release
@@ -289,7 +288,8 @@ row in `ATTRIBUTION.md` (`cargo xtask assets`).
installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner.
- `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`).
**Only the `release` branch builds.** No build workflow runs on `main` or pull requests; every
build job checks `github.ref == 'refs/heads/release'`, so a manual run from another branch is
skipped. Only `packaging-lint.yml` (no build, no secrets) runs on `main` and PRs.
**CI and release builds.** `.github/workflows/ci.yml` runs the shared `cargo xtask ci` gate on
every push to `main` and every pull request. Release artifacts are built on `release` pushes or
manually; manual runs from other branches are dry runs as described above. `packaging-lint.yml`
also runs on `main` and PRs.
- Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check).
+10 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: macro interface on the Automate tab; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -106,7 +106,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w
| Ctrl+S, Ctrl+Shift+S, Ctrl+O, Ctrl+N, Ctrl+W | Cmd | save, save as, open, new, close | present |
| Ctrl+F, Ctrl+H, Ctrl+G, Ctrl+K, Ctrl+T | Cmd (Ctrl+H) | find, replace, go to, hyperlink, table | present |
| Ctrl+D, Ctrl+R, Ctrl+E, Ctrl+1, Ctrl+9 / 0, Ctrl+Shift+9 / 0 | Cmd (Ctrl+E on Mac: see below) | fill down/right, Flash Fill, Format Cells, hide/unhide | present |
| Ctrl+- , Ctrl+Shift+= | Cmd | Delete / Insert cells dialog | present |
| Ctrl+- , Ctrl+Shift+= , Ctrl+numpad + | Cmd | Delete / Insert cells dialog | present |
| Ctrl+; , Ctrl+` | Control+; | current date, show formulas | present (Mac Control+; added) |
| Arrows, Shift / Ctrl / Ctrl+Shift+arrows, Home, Ctrl+Home / End, Page Up / Down, Ctrl+Page Up / Down | Cmd+arrows | navigation and extension | present |
| Tab, Shift+Tab, Enter, Shift+Enter, Ctrl+Space, Shift+Space, Delete, Esc | same | entry and selection | present |
@@ -152,6 +152,10 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one
## Dialogs
Insert Hyperlink loads an existing cell link's address, display text and ScreenTip for editing
(#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel
keeps the original link and an edit can be undone in one step.
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a
@@ -190,6 +194,10 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text |
| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |
| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |
| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift |
| 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty |
| 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified |
| 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays |
@@ -17,6 +17,7 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: gridcraft
finish-args:
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
@@ -40,6 +41,11 @@ finish-args:
- --filesystem=xdg-documents
- --filesystem=xdg-download
# Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted.
# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs
# on cups-client's unstripped tools, in whichever module comes first after they're installed. The
# app's own binaries come from the release tarball, already stripped.
build-options:
no-debuginfo: true
modules:
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
# Linked statically against this release's libcups, so a runtime update can't break them;
@@ -18,6 +18,7 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: gridcraft
finish-args:
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
+24
View File
@@ -6,6 +6,30 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<!-- UI translations are embedded in Rust; declare them for AppKit's system dialogs (open and
save panels, overwrite confirmation). Keep in sync with the interface languages. -->
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>cs</string>
<string>de</string>
<string>el</string>
<string>es</string>
<string>fr</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pl</string>
<string>pt-BR</string>
<string>ro</string>
<string>ru</string>
<string>sr</string>
<string>tr</string>
<string>uk</string>
<string>vi</string>
<string>zh-Hans</string>
</array>
<key>CFBundleDisplayName</key>
<string>GridCraft</string>
<key>CFBundleName</key>