Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick 0ee8c0d15f Merge remote-tracking branch 'origin/main' into test/backfill-regression-tests 2026-10-23 11:03:45 -06:00
aiongg 33a45130a1 Packaging: pin the signing client and appimagetool, and check their hashes (#2073)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Packaging: pin the AppImage runtime and stop clearly without a pinned tool

* Packaging: shellcheck-clean empty hashes
2026-10-23 13:02:16 -04:00
aiongg a7608ada6d CI: one test binary per crate, nextest, line tables only (#2372)
* CI: dev builds keep line tables only

* CI: one integration-test binary per crate

* CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm

cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps.

* CI: docs name cargo xtask test

* CI: agent guides name the moved fuzz test

* CI: tests added on main join their crate's test binary
2026-10-23 12:59:46 -04:00
Dylan M. Taylor bdf8b8cc75 Use chrono instead of hand-rolled UTC conversion (#2121)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies and the
is_leap / days_in_month calendar helpers with chrono as a workspace dependency
(default-features = false, std only, so wasm is unaffected). The spreadsheet
serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial
60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday
numbering and the 1900/1904 systems keep their explicit special cases in
serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs
the plain proleptic-Gregorian day-number <-> date conversion and the
leap-year / month-length helpers those build on; a small fallback keeps the old
answer for dates outside chrono's NaiveDate range. The duplicate local is_leap
in the financial functions now uses the core one. Output is byte-identical and
pinned by the existing date, parse, financial and logging tests. AGENTS.md now
says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on out-of-range clocks or years; keep calendar results outside chrono's range

- logging::timestamp: chrono's From<SystemTime> unwraps, so a clock past year
  262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
  and read such a clock as the epoch, like the old saturating code. Years past
  9999 are written with {:04} (not %Y, which prefixes a '+').
- date::is_leap: restore the Gregorian leap-year rule for years outside chrono's
  NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic.
- date::days_in_month: compute the December boundary with checked_add so
  days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths
  through days_from_civil's fallback so out-of-range years keep their real
  lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0.
- civil_from_days: add missing docstring.
- AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies

The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback`
copies for dates outside chrono's ±262143-year range, plus a hand-rolled
Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`.
AGENTS.md carved a "narrow fallback" exception for them, which left calendar
arithmetic outside chrono after all.

Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce
the year with `rem_euclid(400)` (leap years and month lengths repeat every 400
years), which also removes the December `i32::MAX` overflow special-case.

chrono is now the only calendar logic, and every output is unchanged: verified
the cycle result equals the old formula over wide ranges and at the i32/i64
extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 =
fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/
`datetime_from_serial` only ever hand the converters in-range years, so their
serials are byte-for-byte the pre-chrono ones (pinned by a new test).

New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both
sides of the epoch, round-tripping, with values from the old formula; the
existing leap/month-length-beyond-range test still passes unchanged. Removed the
AGENTS.md "narrow fallback" exception.

Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check;
clippy --all-targets -D warnings.

* time: preserve out-of-range years in the log timestamp instead of the epoch

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).

apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
  u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
  10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:48:02 -04:00
Dylan M. Taylor 168acc5889 packaging: shell, PowerShell and web page fixes (SonarCloud S7688, S7679, S7682, S8677, S7926) (#2323)
- Shell: [[ ]] for conditional tests, positional parameters copied into
  named locals, explicit return at the end of the env.sh helpers.
- PowerShell: the desktop-shortcut smoke test writes its progress with
  Write-Output instead of Write-Host.
- Web: the viewport no longer sets user-scalable=no, so people can zoom
  the page.
2026-10-23 11:17:38 -04:00
Brandon ThomasandClaude Opus 5.5 f1e29fa828 Review fixes for #2267 and #2292
- range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit).
- Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:15:31 -04:00
Brandon Thomas fe2f9a7582 Merge PR #2357
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas a09454e250 Merge PR #2315
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas bcf55f0f00 Merge PR #2274
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas 4b8725b29b Merge PR #2314 2026-10-23 10:11:12 -04:00
Brandon Thomas e92d8b4e48 Merge PR #2295 2026-10-23 10:11:12 -04:00
Brandon Thomas 817cf80470 Merge PR #2292 2026-10-23 10:11:12 -04:00
Brandon Thomas 2d0fe1f01e Merge PR #2289 2026-10-23 10:11:12 -04:00
Brandon Thomas d1399ff2ca Merge PR #2279 2026-10-23 10:11:12 -04:00
Brandon Thomas 67f13d804c Merge PR #2275 2026-10-23 10:11:12 -04:00
Brandon Thomas 50e5a3e928 Merge PR #2267 2026-10-23 10:11:11 -04:00
Brandon Thomas a725f2dc67 Merge PR #2266 2026-10-23 10:11:11 -04:00
Brandon Thomas 27a33f1ee0 Merge PR #2260 2026-10-23 10:11:11 -04:00
Brandon Thomas 5d0fc0b06a Merge PR #2245 2026-10-23 10:11:11 -04:00
Brandon Thomas 3c62ae27fd Merge PR #2244 2026-10-23 10:11:11 -04:00
Brandon Thomas 073ae2df39 Merge PR #2233 2026-10-23 10:11:11 -04:00
RedBearAKandClaude Opus 5.5 8eda577545 Page Up into frozen rows scrolls the rows below back to the top
With row 1 frozen, Page Up could land on A1, which is always on screen, while the scrolled rows
stayed a page down (row 10, Page Down, Page Up, Page Up ends on row 1 with rows 2-9 hidden). The
same held for Ctrl+Up into frozen rows and Ctrl+Left / Alt+Page Up into frozen columns.

Arrow and paging keys now record the cell they moved from (`GridState::reveal_from`). When such a
move goes from the scrolled rows (columns) into the frozen ones, the scrolled area goes back to
its top (left). Other reveals (clicks, Find, Go To, Name Box, Undo, the control channel, formula
pointing) leave it unset and keep the view where the user scrolled it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:20:18 -08:00
xc4pr 7c1ec1df7d Fill handle: test the Auto Fill Options button on a right-to-left sheet, merge the duplicate fill handle row (#1403) 2026-10-23 02:37:49 +02:00
xc4pr 60776ff4de Hyperlinks: test that Ctrl+click beside the link text still extends the selection (#1198) 2026-10-23 02:23:37 +02:00
xc4pr d19f656014 Fill handle: test the Auto Fill Options button's workbook-changed rule, report an undo error, document it (#1403) 2026-10-23 02:17:38 +02:00
xc4pr 6febf5442f Fill handle: Auto Fill Options button after a drag offers Copy Cells, Fill Series and the formatting modes (#1403)
A left-drag of the fill handle filled at once and offered no choice. A button now appears below the filled range; its menu undoes that fill and reruns edit.autoFill in the chosen mode. It goes away when the workbook changes or the selection moves.
2026-10-23 01:25:21 +02:00
xc4pr d046ba6aa7 Hyperlinks: Ctrl+click on a link follows it instead of extending the selection (#1198)
A Ctrl+click went to the add-to-selection branch, so the link under the pointer was never followed. It now goes through follow_link like a plain click (asks before opening web addresses, jumps to references).
2026-10-23 00:44:34 +02:00
Brandon ThomasandClaude Opus 5.5 ac8da7d49c Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:52 -04:00
Brandon ThomasandClaude Opus 5.5 10fb7d566e macOS packaging: retry stapler when Apple's CloudKit drops the connection
stapler staple/validate fetch the notarization ticket from CloudKit, which
sometimes fails with "The network connection was lost" (Error 68). That
failed this release's macOS job twice in a row. Both steps are idempotent,
so retry up to 5 times with backoff, like hdiutil_retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:17 -04:00
Brandon ThomasandClaude Opus 5.5 73af8c6a6d Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:38:06 -04:00
D. Fender 09f78cb3a8 Tables: Format as Table inside a table restyles it
Home > Format as Table on a cell or range inside an existing table
failed with "A table can't overlap another table." Excel applies the
picked style to that table instead. The command now returns that table
and its range, as it does for a new table. A range crossing a table's
edge is still rejected.

Without a range, the active area of the selection decides, as it does
for Insert > Table. The native Format menu's Format as Table now runs
home.formatAsTable instead of insert.table.
2026-10-22 11:43:58 -07:00
coolvitto a8e2ed31b1 Add extra Japanese translations for UI elements
Added extra labels for ribbon menus and pop-ups in Japanese.
2026-10-23 03:05:42 +09:00
skylar taylor-barrick bc0cd89225 #1485: test that the PivotTable field-list scrollbar sits at the pane edge
#1485 let the field list take the pane's width so its scrollbar sits at the pane edge, without a test. With 14 short field names the scrollbar otherwise sits beside them, about 250 px from the edge.
2026-10-22 11:50:34 -06:00
skylar taylor-barrick 524ce912e5 #360: test that Watch Window stays inside the Formulas ribbon
#360 (#342) moved Watch Window out of a three-row column it overflowed, without a test. The test checks Watch Window's painted label ends no lower than the ribbon's last label row; as the fourth small button it ends 15 px below.
2026-10-22 11:50:34 -06:00
skylar taylor-barrick ebc986fcc9 #223: test that the title bar's Save includes the cell being typed
#223 made the title bar's Save button (and the Save Changes dialog's) commit the cell edit before saving, without a test for the button. The test types into A1, clicks Save, and reads the saved file back; with the commit_edit call removed it fails because the file lacks the value.
2026-10-22 11:50:34 -06:00
NetterSilverandClaude Haiku 5.5 9cbb674c55 Print numbers near f64::MAX in a form that parses back
Found while testing crates/formula/src/printer.rs:134

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:34:06 +02:00
BFlat d1db71d0f0 Merge pull request #2209 from Higgs32584/fix/2178-script-trust-gate
scripts: require workbook trust before running embedded scripts (#2178)
2026-10-23 00:25:36 +07:00
Higgs32584andClaude Opus 5.5 b5b3969377 scripts: trust gate review fixes (#2178)
- automate.trustScripts: only a missing `trusted` defaults to true; a
  non-boolean value is an error and leaves the workbook untrusted.
- The refusal message points at Trust Scripts in View > Macros (Alt+F8)
  and at automate.trustScripts.
- Catalog row for automate.trustScripts; parity checklist regenerated.
- Script steps run inside an io::ScriptSandbox (thread-local) so
  read_file/write_file refuse whatever the parameter is called, and a
  step that raises OpenUrl is dropped and stops the script. The id/param
  deny list stays as the up-front refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:43:48 -04:00
Higgs32584 6dedb9310b Merge remote-tracking branch 'origin/main' into fix/2178-script-trust-gate 2026-10-22 12:32:28 -04:00
Dominik StrebelandClaude Opus 5.5 74f29a743f docs: Ctrl+Shift+A (argument names) is no longer listed as missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:18:45 +02:00
Dominik StrebelandClaude Opus 5.5 e6ffca60bb view: hidden outline symbols answer no clicks; parity rows updated
With headings hidden too, a level button sat across the empty column band
at the grid's top edge, so a click there still ran it. Outline hit testing
now stops when both bands are empty. The parity doc no longer lists Ctrl+8
as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:09:29 +02:00
Dominik StrebelandClaude Opus 5.5 5bcbaacce7 Snapshot: catalog installed fonts before rendering
The offscreen snapshot never started the background font scan the app runs
from its first frame, so a workbook's installed fonts (Helvetica Neue, say)
were drawn in the Calibri substitute. Catalog them up front and run a few more
frames so they bind, as in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:02:58 +02:00
Dominik StrebelandClaude Opus 5.5 840ba5c3a5 view: Show Outline Symbols (Ctrl+8), read and written
A sheet can hide its outline's level buttons and brackets while keeping
the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't
modelled, so such sheets showed a wide outline band Excel doesn't.

- model: Sheet::show_outline_symbols (default on, also for older JSON).
- xlsx: read and written on the sheet view.
- engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable.
- ui: no outline bands while it's off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:54:06 +02:00
Hamza 99f92f4a28 range.setValues: parse: false stores a string as the text it is (#2255)
Every string in `values` went through typed input, which reads it with en-US
rules, and nothing said so:

    "05.04.2026"  (dd.mm.yyyy)  → the date 4 May 2026
    "02/11/2026"  (dd/mm/yyyy)  → the date 11 February 2026
    "2.500"       (thousands)   → the number 2.5
    "4.25 %"      (text)        → 0.0425, formatted 0.00%

A workbook built from Turkish-format exports therefore held different values
than the script put in it, and the only way to notice was to read the saved
file back cell by cell.

`parse: false` stores every string as the text it is. A formula is still a
formula — `=` means a formula in a cell, and the ticket asks for that — and
numbers, booleans and null are unaffected either way. A leading apostrophe is
part of the text rather than the marker that makes it text, since nothing is
being marked.

The default is unchanged: `parse` is absent in every existing call, and those
calls behave exactly as before. A `parse` that is neither true nor false is a
usage error rather than a quiet default.

The test pins both sides — the swapped date, the decimal point and the
percentage under today's default; each of the six strings stored verbatim with
`parse: false`, with `=1+1` still calculating 2; numbers and booleans either
way; and the refused `parse` values. Without the change the first string comes
back as a date.
2026-10-22 18:05:53 +05:00
Dominik StrebelandClaude Opus 5.5 843c9b8404 ui: a too-wide number in a repeat-fill format shows ####
A number too wide for its column is drawn as ####. With a repeat fill
(`*` in the format, as in the Accounting format) the fill step then
rebuilt the text from the formatted value, so it overflowed and, being
right-aligned, lost its leading digits: 2,509.00 CHF in a narrow column
was drawn as "509.00 CHF". The fill now leaves #### alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:00:06 +02:00
Hamza 56207f2d3c run --script: a line's unknown key stops the run (#2256)
A script line whose parameters sat under a key the runner does not read ran the
command with no parameters at all, and the run still exited 0:

    {"command":"home.insertSheet","args":{"name":"Report"}}
    {"command":"home.insertSheet","name":"Report2"}

made Sheet2 and Sheet3 instead of Report and Report2, and said nothing. A typo
in a script produced a valid, wrong workbook.

A line is read for `command` (or `id`) and `params`; any other key is a usage
error now, naming the keys and the line, and the run stops before the workbook
is written:

    gridcraft-cli: s.jsonl:2: unknown key `args`: a line is {"command": "id", "params": {…}}

The usage text says what a line looks like, which is the other half of the
report — the format was not written down anywhere, so the reporter found it by
trying `args`, a flat object and `id` in turn.

Tested at both levels: `parse_command` for the keys, their sorted list when
there are several, and the forms that are read (`id`, `params`, `id={json}`,
comments); and a run of a four-line script that stops at line 2 without writing
the file, then the same script corrected, whose sheets come out named. Without
the check the mistaken line parses again.

An unknown key *inside* `params` is still ignored — the rest of #2256 — and
needs the command's parameter list, which the engine does not expose yet. Noted
on the issue.
2026-10-22 17:50:09 +05:00
BFlat a1c84547e3 Merge pull request #2249 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:55 +07:00
bflatastic 7ed494691c README: the app icon above the title 2026-10-22 17:57:56 +07:00
NetterSilverandClaude Opus 5.5 8307d3cb05 Treat empty instance_num in TEXTBEFORE/TEXTAFTER as 1
Found while testing crates/functions/src/text.rs:553

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:25:54 +00:00
NetterSilverandClaude Opus 5.5 c39d2ad197 Keep '!' in structured-reference columns intact on XLSX save
Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:47:21 +02:00
Tyrel HavemanandClaude Opus 5.5 742a6e8535 functions: REGEXREPLACE bounds each replacement as it expands it (#2162)
From the local CodeRabbit review: the result was bounded per match, but
one match's expansion was built whole first, so a replacement repeating
$0 10,000 times over a 32,767-character match allocated about 330 MB
before the check. The replacement is now split into pieces as regex's
Captures::expand reads it (template_expands_like_regex checks they agree)
and appended piece by piece within the bound: 327 MB peak became 7 MB
(regexreplace_bounds_its_result).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-21 23:45:50 -07:00
Tyrel HavemanandClaude Opus 5.5 e177bf1c44 calc: hardening: overlong digits, NaN indexes, REGEXREPLACE output (#2163, #2162)
- Text coercion: digits too many for a finite number are not a number,
  and a fraction must come out finite, so "1 <400 digits>/<400 digits>"
  (inf/inf) no longer coerces to NaN (test overlong_digits_stay_text).
- VLOOKUP/HLOOKUP and the D-functions' field index refuse NaN, which
  passed their "< 1" checks and became index 0, then underflowed
  (test nan_index_is_a_value_error; hostile_inputs_never_panic now also
  passes NaN and infinity to every function).
- REGEXREPLACE replaces match by match and gives up once the result can
  only be too long, instead of building every replacement first: the
  32,767 x 32,767 worst case went from 1.05 GB peak to 7 MB
  (test regexreplace_bounds_its_result).

#2165 (MAP's size check) is already fixed on main by the broadcast cap
(836b2698, #2182), whose test covers MAP.

Also adds my contributors/people.toml entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-21 23:41:28 -07:00
Viorel ToaderandClaude Opus 5.5 b9801f3f81 Romanian: translate Options|Editing; 22 strings main added since (sheet tab menu, account, chart and error-checking labels)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-22 07:55:04 +03:00
aiongg 06920de8da Merge pull request #2226 from aiongg/next
Backlog integration: 230 reviewed fixes and reference app behaviour changes
2026-10-22 00:53:18 -04:00
aiongg 6f10378b17 Integration: #1320 font-measure AutoFit tests in their own test binary
They run the process-wide installed-font scan, whose faces bind from the
next frame; in the same process as the header drag tests they changed text
widths under them, which failed those tests about 1 run in 4 in parallel.
2026-10-22 04:22:36 +00:00
aiongg c4c7f747c7 Integration: keytip NR test gives the sheet data so the Insert Chart dialog opens 2026-10-22 03:54:39 +00:00
aiongg 818175f51e Integration: Japanese catalog covers the reference catalog's new keys; clear the texture delta in the completion test 2026-10-22 03:32:41 +00:00
aiongg c7016fb724 Integration: fix struct literals and call signatures after combining tracks (Series.quartiles, Comment.note_box, Image data Arc, signature language) 2026-10-22 03:11:35 +00:00
aiongg cde364f269 Integration: cargo fmt after combining tracks 2026-10-22 03:08:30 +00:00
aiongg d5cdd2ea2b Integration: track t15
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
2026-10-22 03:05:39 +00:00
aiongg 561382cc6d Integration: track t14
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/formula/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
#	docs/ui-parity.md
2026-10-22 03:05:30 +00:00
aiongg ca1cdd4229 Integration: track t13
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/engine/src/cmd/data.rs
#	crates/xlsx/src/tests/mod.rs
#	docs/chart-parity.md
#	docs/file-format-parity.md
#	docs/function-parity.md
#	docs/gaps.md
#	docs/localization-parity.md
#	docs/target-app-parity.md
2026-10-22 03:05:18 +00:00
aiongg c7fd66d807 Integration: track t12
# Conflicts:
#	ROADMAP.md
#	crates/calc/src/tests.rs
#	crates/chart/src/tests.rs
#	crates/engine/src/tests.rs
#	crates/xlsx/src/tests/mod.rs
#	docs/chart-parity.md
2026-10-22 03:05:03 +00:00
aiongg 9350ab1def Integration: track t11
# Conflicts:
#	ROADMAP.md
#	apps/gridcraft-web/Cargo.toml
#	crates/ui-egui/src/i18n.rs
#	docs/control-protocol.md
#	docs/gaps.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-22 03:04:52 +00:00
aiongg 4c5c345c31 Integration: track t10
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/tests/sheet_tabs.rs
#	crates/xlsx/src/tests/roundtrip.rs
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-22 03:04:30 +00:00
aiongg 6dda6be2c6 Integration: track t09
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/pt.rs
#	docs/file-format-parity.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-22 03:04:21 +00:00
aiongg d3201e7cea Integration: track t08
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
2026-10-22 03:03:55 +00:00
aiongg a680ac59f0 Integration: track t07
# Conflicts:
#	crates/calc/src/tests.rs
#	docs/function-parity.md
2026-10-22 03:03:23 +00:00
aiongg cfda01c831 Integration: track t06
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/tests/interaction.rs
#	docs/gaps.md
#	docs/localization-parity.md
2026-10-22 03:03:17 +00:00
aiongg 4c81d658ec Integration: track t05
# Conflicts:
#	crates/ui-egui/tests/keyboard_shortcuts.rs
#	docs/ui-parity.md
2026-10-22 03:01:39 +00:00
aiongg 512b2aa9fd Integration: track t04
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/formulas.rs
#	crates/engine/src/tests.rs
#	crates/formula/src/lib.rs
#	crates/formula/src/parser.rs
#	crates/formula/src/tests.rs
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/formula_bar.rs
#	crates/ui-egui/src/grid.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
#	crates/ui-egui/tests/selection.rs
#	docs/function-parity.md
#	docs/ui-parity.md
2026-10-22 03:01:31 +00:00
aiongg b0b6409112 Integration: track t03
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/tests/keyboard_shortcuts.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
#	docs/ui-parity.md
2026-10-22 03:00:13 +00:00
aiongg 54d0f7cfc0 Integration: track t02
# Conflicts:
#	ROADMAP.md
#	crates/core/src/lib.rs
#	crates/ui-egui/src/editor.rs
#	crates/ui-egui/src/grid.rs
#	crates/ui-egui/src/tabs.rs
#	docs/chart-parity.md
#	docs/localization-parity.md
#	docs/ui-parity.md
2026-10-22 02:59:55 +00:00
aiongg f193f0d834 Integration: track t01 2026-10-22 02:58:49 +00:00
aiongg d056570d97 Integration: cargo fmt 2026-10-22 02:57:51 +00:00
aiongg 21c3ec248c Integration: cargo fmt 2026-10-22 02:51:29 +00:00
aiongg ec61d59b41 Integration: cargo fmt 2026-10-22 02:50:46 +00:00
aiongg 55aeed6f1a Integration: cargo fmt 2026-10-22 02:48:11 +00:00
aiongg 1b4b51225d Integration: #1912 restore the end of add_page lost in the merge resolution 2026-10-22 02:47:32 +00:00
aiongg b35b5a8b9d Integration: cargo fmt 2026-10-22 02:46:29 +00:00
aiongg 7b47c5a846 Integration: #995 test builds Image.data as Arc<Vec<u8>> (after #1629)
shared_media.rs predates the Arc change; literals and comparisons use .into() and to_vec().
2026-10-22 02:46:21 +00:00
aiongg 55877df80f Integration: cargo fmt 2026-10-22 02:46:00 +00:00
aiongg f02f446905 Integration: #1194 drop the duplicate Cut and Copy German arms (#1830 already translates them) 2026-10-22 02:45:54 +00:00
aiongg 4ac3f2fa64 Integration: cargo fmt 2026-10-22 02:45:35 +00:00
aiongg 1d9b29ff57 Integration: cargo fmt 2026-10-22 02:45:22 +00:00
aiongg c37298b468 Integration: #1151 test literal gains the category_levels field from #1088 2026-10-22 02:40:38 +00:00
aiongg 36c9df453f Integration: order localization-parity revision rows newest first 2026-10-22 02:39:29 +00:00
aiongg 47bf083d0a Integration: #2116 move name_sort_key and its test into the existing tests module 2026-10-22 02:39:21 +00:00
aiongg a1c1fc05de Merge PR #2116: Backstage: list the languages alphabetically by their own name 2026-10-22 02:39:08 +00:00
aiongg d4eabcee96 Merge PR #2106: Translate form-dialog field labels, choices and notes 2026-10-22 02:39:04 +00:00
aiongg 10ac46c243 Integration: #2102 fill Romanian catalog gap (Options|Editing, English text) 2026-10-22 02:39:04 +00:00
aiongg 0eeaeec0c1 Merge PR #2102: Romanian (ro) interface language
Conflicts:
- crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 18 entries).
- crates/ui-egui/tests/file_menu.rs: both language rows kept.
- docs/localization-parity.md: one Last updated line naming all changes; both revision rows kept, newest first.
2026-10-22 02:38:43 +00:00
aiongg e72d5f290a Merge PR #2061: i18n: Dutch (nl) interface language
Conflicts:
- crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 17 entries).
- ROADMAP.md: one Last updated line naming both changes; both progress-log rows kept.
- docs/localization-parity.md: one Last updated line naming both changes; both revision rows kept.
2026-10-22 02:38:32 +00:00
aiongg 905ef6a6a5 Integration: #2000 remove duplicated Spanish financial alias block 2026-10-22 02:38:15 +00:00
aiongg 4286ac2930 Merge PR #2000: Spanish formula aliases for financial functions (#1986) 2026-10-22 02:38:09 +00:00
aiongg 7649a762ff Merge PR #1772: i18n: add Vietnamese interface translations
Conflicts:
- crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 16 entries).
- docs/localization-parity.md: both revision rows kept.
2026-10-22 02:38:09 +00:00
aiongg e4c406dbcf Merge PR #1194: fix(ui): ribbon tooltips show each platform's shortcuts (Ctrl+B on Windows, ⌘B on macOS) (#1191)
crates/ui-egui/src/widgets.rs: both test modules kept.
2026-10-22 02:37:51 +00:00
aiongg 8bfe310208 Integration: #1770 register Ukrainian (Language::Uk) and fill 573 missing keys with English text 2026-10-22 02:37:49 +00:00
aiongg a9a8fe7d20 Integration: #1754 translate the sheet-tab Delete button; Spanish Hide and Autoguardado; test that the sheet-tab and AutoSave labels translate in es, de, fr, ko, ru, zh, sr 2026-10-22 02:37:48 +00:00
aiongg 3a01ce689e Integration: i18n drop duplicate Unprotect Sheet… entries (added by #1373 and #1754) 2026-10-22 02:37:43 +00:00
aiongg 9d3252189d Merge PR #1180: fix(ui): menu items highlight under the pointer (#1174)
crates/ui-egui/src/grid.rs: list picker button combines frame_when_inactive(false) with main's LIST_PICKER_ROW_H min size.
2026-10-22 02:37:42 +00:00
aiongg 1316d39865 Merge PR #1151: Charts: a huge trendline forecast no longer collapses the chart (#1148)
ROADMAP.md: all rows kept.
crates/chart/src/tests.rs: both tests kept.
2026-10-22 02:37:37 +00:00
aiongg 56c31b0b31 Merge PR #1993: Grid: the selection outline follows a mouse drag at once
Conflicts:
- docs/ui-parity.md: header keeps main's change list plus the outline entry; both log rows kept
2026-10-22 02:37:23 +00:00
aiongg 169c78dda8 Merge PR #1111: Respect workbook structure protection in sheet rename, move, copy, hide and unhide (#1049) 2026-10-22 02:37:22 +00:00
aiongg d8b73ecb4e Merge PR #1110: Size the list validation dropdown to up to 8 rows (#1089) 2026-10-22 02:37:19 +00:00
aiongg f5b35fb3ac Merge PR #1109: fix(ui): commit a pointed formula on the sheet it was started on (#996)
crates/ui-egui/src/lib.rs: sheet re-activation first, then main's unchanged-empty-commit check and the commit chain.
crates/ui-egui/src/tabs.rs: union of test imports.
2026-10-22 02:37:19 +00:00
aiongg 470a2ae2c1 Merge PR #1765: Sheet protection: obey the Sort and Use AutoFilter boxes
Conflicts:
- crates/engine/src/cmd/data.rs: do_sort keeps #1284's sheet parameter and checks Sorting on it; toggle_filter checks Filtering after #1285's data_target
- crates/engine/src/tests.rs: adjacent tests, both kept
2026-10-22 02:37:16 +00:00
aiongg ad1899e1ca Merge PR #1770: Add Ukrainian localization (uk.rs) 2026-10-22 02:37:08 +00:00
aiongg 01485e2b66 Merge PR #1838: Added multi-column/row selection in formulas & column/row selection highlighting.
crates/ui-egui/src/editor.rs: kept both appended tests
crates/ui-egui/src/grid.rs: kept the Table heading filter-button check, then point_header(.., mods.shift, mods.command); kept format_painter_apply and the extend/add point_header
2026-10-22 02:37:08 +00:00
aiongg 37bf37d181 Merge PR #1103: Keep chart cache point positions when a reference has blank edges (#1015)
crates/xlsx/src/chart.rs: 1103's used-range clipping applied in ref_values after 1088's ref_target call.
2026-10-22 02:37:08 +00:00
aiongg f4f18bb368 Merge PR #1701: XLSX: new workbooks get <fileVersion appName="gridcraft"/>
Conflicts:
- crates/xlsx/src/write.rs: keeps #1701's fileVersion block followed by #1626's workbookPr attributes (showObjects, date1904)
2026-10-22 02:37:05 +00:00
aiongg 31d6221463 Integration: #1725 prefill Remove Duplicates with the current region when only the cursor is placed 2026-10-22 02:37:01 +00:00
aiongg 59e6f123ba Integration: #2095 error-check colours come from theme tokens 2026-10-22 02:36:59 +00:00
aiongg ad7fe4ed70 Merge PR #1095: fix(grid): keep cursor stable during cell dragging (#936)
crates/ui-egui/src/grid.rs: keep main's touch-pan guard around interact, add 1095's update_cursor call after it.
docs/ui-parity.md: one Last updated line, change notes combined; both history rows kept.
2026-10-22 02:36:58 +00:00
aiongg 857bdae023 Merge PR #1692: XLSX: keep Japanese phonetic runs on open and save
Conflicts:
- crates/model/src/lib.rs, crates/xlsx/src/read.rs, crates/xlsx/src/write.rs: use lists unioned (ShowObjects and the phonetic types)
- docs/file-format-parity.md: main's header and rows kept (#1692 collapsed the three duplicate .xlsx rows; they stay), phonetic-runs text added to each; Last updated 2026-10-20
2026-10-22 02:36:58 +00:00
aiongg 46ddd61021 Merge PR #1732: i18n(ja): translate the dialog, message and menu strings (943 entries, matching the pt-BR coverage)
Conflicts:
- crates/ui-egui/src/i18n.rs: mod it and mod ja both kept; Language::Ja dispatches to ja::translate; the shape test (PR #831) iterates ja::CORE, which replaces JAPANESE.
- docs/localization-parity.md: both revision rows kept, newest first.
2026-10-22 02:36:53 +00:00
aiongg 48285a8fc4 Merge PR #1754: i18n: translate sheet tab context menu and AutoSave toggle (fixes #1537)
crates/ui-egui/src/i18n/de.rs: kept both sets of entries; dropped the duplicate Unprotect Sheet… line
crates/ui-egui/src/tabs.rs: kept active_sheet_protected() and the protectSheet dialog id, tr() on both button labels
2026-10-22 02:36:53 +00:00
aiongg e738049157 Merge PR #1088: Charts: multi-level category axes (year beside month) read, drawn and written back
ROADMAP.md: all rows kept.
2026-10-22 02:36:49 +00:00
aiongg 75f6c92eab Integration: #2086 test that the strip beside the column headings is filled in dark mode 2026-10-22 02:36:41 +00:00
aiongg 6432253de9 Integration: #973 move the sheet-tab copy row from the element table to the change history 2026-10-22 02:36:40 +00:00
aiongg cd3fcfd2a0 Merge PR #1083: Colour menus: More Colors… and Recent Colors (#928)
ROADMAP.md: all progress-log rows kept.
docs/ui-parity.md: one Last updated line, change notes combined; both history rows kept; ribbon row wording tidied.
2026-10-22 02:36:40 +00:00
aiongg c9941763cc Integration: #2093 move the paste_special_shortcuts doc comment back onto its function 2026-10-22 02:36:38 +00:00
aiongg d44c08c354 Merge PR #2093: Paste Special: the reference app's two-column layout, translated, Paste Link button
crates/ui-egui/src/dialogs.rs: window width match keeps both zoom and pasteSpecial arms
docs/ui-parity.md: status line combined, change-log rows both kept
2026-10-22 02:36:35 +00:00
aiongg 6370fd2254 Merge PR #1693: Charts: series that refer to defined names ([0]!Name) plot their data, and are saved qualified
crates/xlsx/src/tests/mod.rs: kept both mod lines (cellmetadata, chart_book_names)
docs/file-format-parity.md: kept HEAD header line, both change rows
2026-10-22 02:36:34 +00:00
aiongg e6ccfba926 Merge PR #2142: PivotTable Fields pane: scan the source once per change, not every frame 2026-10-22 02:36:33 +00:00
aiongg fccca928b3 Merge PR #1631: Memory: floating pictures' textures are bounded, sized to what is drawn, and dropped with their workbook
Conflicts:
- ROADMAP.md: header change line names both memory changes; both progress-log and revision rows kept
2026-10-22 02:36:33 +00:00
aiongg 431aaaaf69 Merge PR #2022: Reject unparseable conditional-format formulas (#1999)
crates/engine/src/cmd/insert.rs: kept both sides: PR #884's is_string_literal and cf_style_json, and PR #2022's check_cf_* before edit(); 884's kept string literals parse
2026-10-22 02:36:33 +00:00
aiongg 70426fc913 Merge PR #1657: Cell editor: the caret contrasts with the cell's fill 2026-10-22 02:36:24 +00:00
aiongg 8ec29a8b3b Merge PR #1652: Fill handle: right-drag opens the fill menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…)
crates/ui-egui/src/grid.rs: kept both GridState fields (context_menu_frame, fill_menu); previous_popups tuple has four elements inside the touch_pan branch; release match has Drag::Rows|Cols, then the two Drag::Fill arms
docs/ui-parity.md: kept HEAD header line, both change rows
2026-10-22 02:36:24 +00:00
aiongg d97f61748e Merge PR #1946: XLSX: keep cell fonts' charset (Arabic 178) and family on save
crates/xlsx/src/styles.rs: family/charset block ported into the dxf.is_none() branch of font_xml (PR #1225 made it take Option<&DxfStated>)
docs/file-format-parity.md: header Change line keeps both sides' clauses
2026-10-22 02:36:23 +00:00
aiongg 8e2974cc65 Merge PR #1629: Memory: undo steps share picture and macro bytes instead of copying them
Conflicts:
- ROADMAP.md: both progress-log and revision rows kept
- crates/xlsx/src/sheet_write.rs: keeps #995's image_part call; drops #1629's to_vec line (image_part copies from &[u8])
2026-10-22 02:36:22 +00:00
aiongg 42768628c4 Merge PR #1473: i18n: add Italian translation
Conflicts: crates/ui-egui/src/i18n.rs: language registration lines kept from both sides (Language::ALL has 14 entries).
2026-10-22 02:36:21 +00:00
aiongg 826a22dc2b Integration: #2131 remove a leftover conflict marker line in ROADMAP.md 2026-10-22 02:36:21 +00:00
aiongg cba89cfda8 Merge PR #1066: Engine: formulas.evaluate refuses what cell entry refuses, saying why
ROADMAP.md: both progress-log rows kept.
2026-10-22 02:36:20 +00:00
aiongg 90d4c3af0f Merge PR #1981: Desktop theme: the watcher's link has a type off Linux, so the gate passes there 2026-10-22 02:36:20 +00:00
aiongg 608925af08 Merge PR #1979: Seed the Sort dialog column from the active cell (#1906) 2026-10-22 02:36:20 +00:00
aiongg ced3f7ce7a Merge PR #1912: Print CJK text with a CID font instead of question marks
crates/pdf/src/lib.rs: add_page keeps both fonts [bool; FONT_SLOTS] and links Vec
2026-10-22 02:36:20 +00:00
aiongg 7fd1553a8b Merge PR #1626: Keyboard: Ctrl+6 hides and shows objects, saved as workbookPr showObjects
Conflicts:
- docs/file-format-parity.md: header and the three .xlsx rows keep main's text plus the show-objects entry; both log rows kept
- docs/ui-parity.md: header keeps main's text plus Ctrl+6; both log rows kept
2026-10-22 02:36:15 +00:00
aiongg a8f74730c1 Merge PR #1056: Fix Command-click and Shift-click references during formula entry
contributors/people.toml: union of both new entries.
docs/ui-parity.md: one Last updated line, change notes combined; both history rows kept.
2026-10-22 02:36:14 +00:00
aiongg 929c506d1b Merge PR #2100: Fonts: a cell font named in another language matches its installed font (fixes #1898) 2026-10-22 02:36:12 +00:00
aiongg 8590dca0db Merge PR #2207: clipboard: bound HTML colspan/rowspan expansion (#2171) 2026-10-22 02:36:12 +00:00
aiongg 7221e11f56 Merge PR #1945: XLSX: keep the theme's per-script fonts (<a:font script="Arab">) on save (stacked on #1740, included)
Conflicts:
- docs/file-format-parity.md: kept main's three .xlsx rows (the PR's docs hunk replaced them with one) and added the theme-font wording to each; header keeps main's change list plus this PR's phrase (updated 2026-10-20)
2026-10-22 02:36:12 +00:00
aiongg d0394a0f34 Merge PR #2005: AutoFill refuses a target that doesn't hold the source in a corner (whole rows into whole columns aborted the app)
crates/engine/src/fill.rs: kept both tests (#1360 and #2005).
2026-10-22 02:36:12 +00:00
aiongg d67193f403 Merge PR #1685: #1447: a PivotChart's link names the workbook
docs/file-format-parity.md: header Change line names both pivot fixes; both change log rows kept
2026-10-22 02:36:11 +00:00
aiongg 75e84a26d5 Merge PR #1612: Grid: a scrolled Table's header names and filter buttons in the column headings (#1423)
docs/ui-parity.md: kept HEAD header line, both change rows, newest first
2026-10-22 02:36:10 +00:00
aiongg 823914157a Merge PR #2200: core: iterative number coercion for nested parentheses (#2180) 2026-10-22 02:36:10 +00:00
aiongg 07f806d3e5 Merge PR #2098: docs: fix Trunk website link (#2076) 2026-10-22 02:36:10 +00:00
aiongg 635056bd23 Merge PR #2131: Print: JIS B4/B5 and Japanese paper sizes (はがき・封筒・B6)
ROADMAP.md: one status line naming both changes; both feature-table and progress-log rows, newest first.
2026-10-22 02:36:10 +00:00
aiongg edb214b236 Merge PR #1725: Prefill the Remove Duplicates range from the selection
docs/ui-parity.md: status line combined, change-log rows both kept
2026-10-22 02:36:09 +00:00
aiongg 78b7ef49fd Merge PR #1889: Docs: correct stale chart round-trip notes 2026-10-22 02:36:08 +00:00
aiongg 7963cdb218 Merge PR #1758: Fonts: render Lao text with an installed Lao face instead of tofu
Conflicts:
- docs/localization-parity.md: status line, script-support text and history rows from both sides kept
2026-10-22 02:36:08 +00:00
aiongg 7269b12f1e Integration: #1976 scenarioShow writes stored numbers as values
Entering them through the parser applied percent entry to Percentage-formatted
cells (a stored 0.05 would not round-trip). Test: scenario_show_restores_a_percentage_cells_stored_number.
2026-10-22 02:36:05 +00:00
aiongg c03d4a881b Merge PR #1570: XLSX: keep dynamic-array cell metadata (cm) on save
ROADMAP.md: kept both progress-log rows
2026-10-22 02:36:04 +00:00
aiongg 7e74b35a1b Merge PR #2095: Number Stored as Text: green indicator, warning button and Convert to Number (#1994)
crates/ui-egui/src/grid.rs: kept object_menu (2084) and error_check_tag (2095), both fns and both calls.
crates/ui-egui/src/i18n/de.rs, pt.rs: union of the entries.
docs/ui-parity.md: one status line naming both changes, both history rows.
2026-10-22 02:36:03 +00:00
aiongg 66e6c9f54e Integration: #1468 fill Greek catalog gap (Options|Editing, English text) 2026-10-22 02:36:01 +00:00
aiongg 508d230f7f Merge PR #1683: #1447: two value fields without a column field hide the Values row 2026-10-22 02:36:01 +00:00
aiongg 5d0d1c2b95 Merge PR #973: Sheet tabs: Ctrl-drag (Cmd or Option on macOS) drops a copy of the sheet
docs/ui-parity.md: one Last updated line, change notes combined.
2026-10-22 02:36:00 +00:00
aiongg 05ecd6d4a8 Merge PR #1628: Add Advanced Filter dialog and fix copy-to data loss
crates/ui-egui/src/control.rs: kept #1477's unconditional build_params call
crates/ui-egui/src/dialogs.rs: build_params pub(crate) once, with #1628's advancedFilter arm
docs/ui-parity.md: status line combined, change-log rows both kept
2026-10-22 02:35:59 +00:00
aiongg 2676c0894b Merge PR #1671: fix: theme PivotTable field group buttons 2026-10-22 02:35:59 +00:00
aiongg 8de6c8376a Merge PR #1560: ui: truncate long workbook titles in the title bar 2026-10-22 02:35:58 +00:00
aiongg 7768f56603 Merge PR #1580: Calc: CELL("filename") names the workbook's file 2026-10-22 02:35:56 +00:00
aiongg 7c94f9c9bb Merge PR #1512: Reject formula number literals too large for f64 as parse errors 2026-10-22 02:35:56 +00:00
aiongg 6b0411091a Merge PR #1559: Sort and de-duplicate data.textToColumns fixed-width breaks
Conflicts:
- crates/engine/src/cmd/data.rs: adjacent test insertions, both kept
2026-10-22 02:35:56 +00:00
aiongg d20fe88b98 Merge PR #1485: UI: keep PivotTable field-list scrollbar at pane edge
docs/ui-parity.md: header and change log lines kept from both sides, newest first, File view line once
2026-10-22 02:35:56 +00:00
aiongg 9c2cc2bff4 Merge PR #2094: Dialogs: OK and Cancel in a row, so form dialogs fit their content 2026-10-22 02:35:55 +00:00
aiongg ee908a2124 Merge PR #1468: Add Greek (el) interface localization
Conflicts: crates/ui-egui/src/i18n.rs: Language::ALL holds both El and Pl (13 entries).
2026-10-22 02:35:55 +00:00
aiongg 2fbb45eac0 Merge PR #2049: Word-wrap-aware automatic row height (#1969)
crates/engine/src/cmd/mod.rs: kept #2033's wrap-only branch and #2049's wrapped_line_count helper, called inside the wrap branch; dropped #2049's explicit/else arm.
2026-10-22 02:35:55 +00:00
aiongg 6f62e74704 Merge PR #1753: Docs: §6's calculation bugs have all landed 2026-10-22 02:35:54 +00:00
aiongg 881b3f5fe2 Integration: restore sync_github_name for marcoarnulfo after merging the contributor entries 2026-10-22 02:35:54 +00:00
aiongg eaf9a3f230 Merge PR #1705: Fonts: find the cell, serif, mono, Thai and Hebrew faces in Fedora's package folders (#1694)
Conflicts:
- crates/ui-egui/src/theme.rs: appended tests from #1630, #1669 and #1705 kept
2026-10-22 02:35:54 +00:00
aiongg 524ab89a1a Merge PR #1379: engine: subtotal honours header:false instead of dropping the first data row
Conflicts:
- crates/engine/src/cmd/data.rs: subtotal keeps #1285's data_target and sheet-qualified calls with #1379's header/first logic; grand-total range starts at first; both tests kept (#1299 filter blanks, #1379 subtotal)
2026-10-22 02:35:51 +00:00
aiongg 6e66756ce8 Merge PR #1976: Enter bare numbers in Percentage-formatted cells as percents 2026-10-22 02:35:44 +00:00
aiongg ff546d8ffb Merge PR #2090: fix(ui): Esc and Enter close a data validation alert, not the edit behind it, and the keyboard goes back to the entry (#2089) 2026-10-22 02:35:43 +00:00
aiongg 44320b393e Merge PR #1648: fix(xlsx): bound repeated column expansion
Conflicts:
- docs/file-format-parity.md: kept main's header and log rows, added this PR's change phrase and row once
2026-10-22 02:35:42 +00:00
aiongg c8a20cfaf6 Merge PR #1674: contributors: add Harrison-Booth
Conflicts:
- contributors/people.toml: all new entries kept
2026-10-22 02:35:42 +00:00
aiongg 0c45bd46b6 Merge PR #1937: Keep date text with signed parts like +3/4/2026 as text
crates/core/src/parse.rs: kept both tests; parse_time keeps the plain_number parsing from #1552 plus the redundant '+' guard from #1937.
2026-10-22 02:35:42 +00:00
aiongg 05e4b5e3d8 Merge PR #2086: Grid: paint the scrollbar's column beside the column headings (white corner in dark mode) 2026-10-22 02:35:42 +00:00
aiongg 7a016c2de9 Integration: #1531 fold the sheet filter into a table only when both start at the same column
A sheet AutoFilter that starts right of the table's first column is not folded
(its colId offsets would hit the wrong column); the sheet autoFilter stays
suppressed for any table it intersects, as before. Test added.
2026-10-22 02:35:41 +00:00
aiongg 46647fd61e Merge PR #1474: Conditional formatting: text rules never match an error value (#1162) 2026-10-22 02:35:41 +00:00
aiongg 013ead7389 Integration: #2033 drop the dated history from the auto-fit comments 2026-10-22 02:35:41 +00:00
aiongg 2aec7f2d5a Integration: #1365 kittest for the font dropdown's filtered highlight and cleared filter 2026-10-22 02:35:41 +00:00
aiongg aab44d2c23 Merge PR #1535: engine: sort and remove duplicates move the rows' notes and hyperlinks
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-22 02:35:40 +00:00
aiongg 5c1b171892 Merge PR #1738: Keep chart source range in step with inserted and deleted lines 2026-10-22 02:35:39 +00:00
aiongg 3ed16929d9 Merge PR #1320: AutoFit Column Width measures text in the font the grid draws it with
Conflicts:
- crates/engine/src/cmd/format.rs: autofit_cols keeps #885's (si, lines) and adds #1320's measure
2026-10-22 02:35:39 +00:00
aiongg 2aaa09119e Merge PR #1669: macOS: bold interface text draws Cyrillic е open, not like ө
Conflicts:
- crates/ui-egui/src/theme.rs: appended tests from both PRs kept; the mut fd hinting tweak applies to the FontData from_static in try_load
2026-10-22 02:35:39 +00:00
aiongg e85afa6fe5 Merge PR #2084: Right-click on a chart opens the chart's menu, not the cell's (fixes #2067) 2026-10-22 02:35:39 +00:00
aiongg d83dc11ff2 Merge PR #1695: Charts: box & whisker charts honor their quartile method
crates/engine/src/tests.rs: kept both new tests (csv_as_text_keeps_identifier_columns_as_written and box_whisker_quartile_methods_survive_xlsx).
2026-10-22 02:35:39 +00:00
aiongg 6d4e62d12b Merge PR #2032: Formula entry: an incomplete formula's error describes what was typed (fixes #2030)
crates/formula/src/tests.rs: kept both tests.
2026-10-22 02:35:39 +00:00
aiongg 4e74bb762b Integration: #1414 kittest for command search keyboard navigation 2026-10-22 02:35:38 +00:00
aiongg 994b2e66c9 Merge PR #1744: Ctrl+Alt+letter that types a character no longer triggers the Ctrl shortcut (#1530)
docs/ui-parity.md: kept the newer header line, kept both history rows.
keyboard_shortcuts.rs: kept both tests (Alt+= and Ctrl+Alt+letter).
2026-10-22 02:35:37 +00:00
aiongg 4fd4d39aa0 Merge PR #1308: engine: refuse removeDuplicates with no valid column instead of deleting all rows
Conflicts:
- crates/engine/src/cmd/data.rs: keeps #1308's column guard after cols is built; drops its later 'let sheet' (#1285 supplies sheet)
2026-10-22 02:35:34 +00:00
aiongg 26c6e4ca14 Merge PR #1621: Reject unsupported flip param in arrange.rotate
Conflicts:
- crates/engine/src/tests.rs: kept both appended tests
2026-10-22 02:35:33 +00:00
aiongg 0118413c77 Merge PR #2080: HTML export: place pictures and charts at their anchors
crates/engine/src/tests.rs: kept both sides' new tests (journal tests from #1230, HTML export tests from the PR).
docs/file-format-parity.md: header Last updated 2026-10-21 with both change clauses; both revision-history rows, newest first.
2026-10-22 02:35:32 +00:00
aiongg b17f47b2ca Merge PR #2033: Row auto-fit: count line breaks only in cells that wrap
ROADMAP.md: header line keeps the newer 2026-10-19 date and lists this change; this PR's progress-log row added once below the newer rows.
2026-10-22 02:35:31 +00:00
aiongg cede13d0a7 Merge PR #1527: engine: pageLayout.printTitles only touches the axes it is given 2026-10-22 02:35:30 +00:00
aiongg 5f4309e4ba Merge PR #1653: Charts: insert.chart builds a series from every area of a multi-area range 2026-10-22 02:35:30 +00:00
aiongg 66c64a9331 Merge PR #2019: Formulas: the reference app's longest legal operator chain (4,096 operands) no longer crashes entry or save
docs/function-parity.md: union of the status line and both history rows.
2026-10-22 02:35:30 +00:00
aiongg c31e25b39d Merge PR #1568: calc: bounded references up to 1,048,576 cells keep their full shape 2026-10-22 02:35:30 +00:00
aiongg e49b2d8830 Integration: #1550 keytip NR expects the Insert Chart dialog
The generator no longer probes insert.recommendedCharts (the engine command errors without a chart type; the UI opens the dialog) and emits an assertion that the recommendedCharts dialog is open. keytips_generated.rs updated to match.
2026-10-22 02:35:30 +00:00
aiongg c29cac503f Merge PR #1477: Control channel: ui.dialog.confirm sends what OK sends (#1459) 2026-10-22 02:35:28 +00:00
aiongg 0e298839aa Merge PR #1292: Preserve hyperlinks in PDF export and support PDF in CLI convert
docs/file-format-parity.md: status line combined, change-log rows both kept
2026-10-22 02:35:28 +00:00
aiongg a2314b5414 Merge PR #1630: Memory: interface fonts are read once and shared with egui, not copied into it 2026-10-22 02:35:26 +00:00
aiongg 77b861392a Merge PR #1299: Filter: count empty text and ="" results as (Blanks) 2026-10-22 02:35:26 +00:00
aiongg c5007d5589 Merge PR #1615: XLSX: keep custom column widths that round to the default width 2026-10-22 02:35:26 +00:00
aiongg faef39cccc Merge PR #1600: XLSX: keep header and footer pictures on save
Conflicts:
- docs/file-format-parity.md: kept main's header and log rows, added this PR's change phrase and row once
2026-10-22 02:35:26 +00:00
aiongg 41bf1f0e52 Merge PR #1547: UI: Delete dismisses the AutoComplete proposal, and deleting text never brings it back (#1521)
crates/ui-egui/src/formula_bar.rs: kept both test modules (spill_tests, tests)
2026-10-22 02:35:25 +00:00
aiongg 8991b7d090 Merge PR #2150: Table Design: draw the Table Styles gallery in the ribbon (#2148) 2026-10-22 02:35:25 +00:00
aiongg 3af424f7ac Merge PR #1624: Keyboard: Ctrl+Shift+A after a function name inserts its argument names
docs/ui-parity.md: kept the newer header line, kept both history rows.
2026-10-22 02:35:25 +00:00
aiongg 195bd22a9a Merge PR #2038: Fix WebGL startup on devices with lower GPU limits 2026-10-22 02:35:25 +00:00
aiongg b4980ebd32 Merge PR #1734: fix(web): clean up print iframe and blob resources
Conflicts: docs/ui-parity.md (header change text and change log: union of both sides)
2026-10-22 02:35:25 +00:00
aiongg 10746f8e33 Merge PR #1614: XLSX: a note keeps its box's place and size through open and save (#1608) 2026-10-22 02:35:24 +00:00
aiongg d691e95f6d Merge PR #1285: Remove Duplicates, Filter and Subtotal: a Sheet! prefix on the range works on that sheet 2026-10-22 02:35:24 +00:00
aiongg c6fb01ff52 Merge PR #1596: Contributors: add Marco Arnulfo
Conflicts:
- contributors/people.toml: both new entries kept
2026-10-22 02:35:24 +00:00
aiongg 4a8765aa27 Merge PR #970: Control: Go To and Select over the control channel scroll the active cell into view 2026-10-22 02:35:22 +00:00
aiongg f8c9f87f6e Merge PR #913: Keyboard: Enter after a run of Tabs goes to the next row in the column the run started from 2026-10-22 02:35:22 +00:00
aiongg b40c0a6d05 Merge PR #2069: Use scientific notation when number_to_text rounds up to 1e15 2026-10-22 02:35:21 +00:00
aiongg 87df1947f9 Merge PR #1284: Sort: a Sheet! prefix on the range sorts that sheet, not the active one 2026-10-22 02:35:21 +00:00
aiongg a9e20d81e2 Merge PR #905: Keytips: Alt, F opens the File view, and its F letters still run
crates/ui-egui/src/lib.rs: keep main's file_tab close guard and 905's keytips condition.
2026-10-22 02:35:21 +00:00
aiongg d5e2b2fc77 Merge PR #1971: Control channel: a request its sender gave up on is not run later 2026-10-22 02:35:21 +00:00
aiongg d26bbe3432 Merge PR #1085: Format: Row Height and Column Width say pixels, and take points like the reference app
Conflicts:
- crates/engine/src/cmd/format.rs: spec strings keep #885's sheet?/Sheet! forms with #1085's pixels/points wording; row_height and col_width keep #1085's unit logic with #885's (si, lines) = line_targets
2026-10-22 02:35:21 +00:00
aiongg 7da18f74ba Merge PR #1942: CSV import: read Windows-1256 Arabic files as Arabic 2026-10-22 02:35:20 +00:00
aiongg cc76df4024 Merge PR #2001: Formula editing: arrow pointing restarts from the edited cell after typing (#1949) 2026-10-22 02:35:18 +00:00
aiongg b388aaa241 Merge PR #1998: ui: language command docs and error list every language 2026-10-22 02:35:18 +00:00
aiongg 1aa5d4ea79 Integration: #1996 same_name compares ASCII names without allocating 2026-10-22 02:35:18 +00:00
aiongg 2023b96434 Merge PR #2201: calc: cap array broadcast result size (#2182)
crates/calc/src/eval.rs (MAP): size cap uses #2201's broadcast_len; #1060's parameter-count check kept after it.
2026-10-22 02:35:17 +00:00
aiongg f68f1e48a1 Merge PR #1414: UI: Improve Command Palette keyboard navigation and layout 2026-10-22 02:35:17 +00:00
aiongg 8709b41666 Merge PR #1525: Keep leading empty columns and rows when saving CSV/text
Conflicts:
- crates/xlsx/src/csv.rs: write_csv doc comment merged (this PR's A1 rectangle sentence, main's per-field cap wording)
- crates/engine/src/tests.rs: kept both appended tests
2026-10-22 02:35:17 +00:00
aiongg 33b6dfd44e Merge PR #1552: Keep nan/inf time and fraction look-alikes as text 2026-10-22 02:35:17 +00:00
aiongg bdb03349f6 Merge PR #1930: Accounting format: the documented currency symbol is honoured 2026-10-22 02:35:17 +00:00
aiongg 0bf7d40f3b Integration: #1534 remove the What::Hyperlinks paste-special target
The hyperlink copy stays inside What::All, which the cut path uses; the existing
test a_cut_moves_the_sources_notes_hyperlinks_and_merges covers it.
2026-10-22 02:35:17 +00:00
aiongg b10e41c1c7 Merge PR #1726: Fail CLI checks on incomplete or empty input discovery 2026-10-22 02:35:17 +00:00
aiongg 9d1f5503c9 Merge PR #1438: Fix fraction numerators saturating at u64::MAX for large values 2026-10-22 02:35:17 +00:00
aiongg 43cd35d4bd Merge PR #1263: Bound Undo history growth after bulk edits
crates/engine/src/lib.rs: kept both the journal caps (#1230) and the undo byte budget; separate constants and call sites.
crates/engine/src/cmd/edit.rs: undo pushes through push_history; the redo entry keeps the undone edit's selection (#1855 behaviour).
crates/engine/src/tests.rs: kept both tests (redo_selects_the_redone_cell and the two history-bound tests).
2026-10-22 02:35:17 +00:00
aiongg 39add6b7c3 Merge PR #2026: Linux: time out the desktop theme's portal read
ROADMAP.md: header line takes the newer date and mentions both changes; both progress-log rows and revision-history rows kept once, newest first.
2026-10-22 02:35:16 +00:00
aiongg 69f6ea363d Merge PR #1529: fix #1458: one reference-app sheet-name quoting rule for formulas, pivot sources and ADDRESS 2026-10-22 02:35:16 +00:00
aiongg 5186b9c964 Merge PR #1523: UI: function help follows the interface language
ROADMAP.md: kept both progress-log rows, newest first
2026-10-22 02:35:16 +00:00
aiongg 4c3dc617cc Merge PR #1365: Fix font dropdown list resizing and keyboard up/down navigation
crates/ui-egui/src/ribbon.rs: ported the sel clamp, sized scroll area and position-based sel into combo_popup, keeping row_w and the filter-only separator
2026-10-22 02:35:15 +00:00
aiongg 8daa1f41e1 Merge PR #1731: Move PivotTables with their cells on row/column insert and delete 2026-10-22 02:35:14 +00:00
aiongg cd5d21740a Merge PR #1326: Reject a list data validation that has no formula1 (#1303)
Conflicts:
- crates/engine/src/cmd/data.rs: kept both; the missing-formula1 guard comes right after the kind match, before the operator and list_source handling
2026-10-22 02:35:14 +00:00
aiongg cfd3e789ac Merge PR #1331: Form dialogs open ready to type (Column Width, Row Height) 2026-10-22 02:35:14 +00:00
aiongg 0592dd0a5e Integration: #1091 fill Polish catalog gap (Options|Editing, English text) 2026-10-22 02:35:14 +00:00
aiongg 0e6b31ab71 Merge PR #1996: calc: LET and LAMBDA names ignore case in any script 2026-10-22 02:35:12 +00:00
aiongg 7116252960 Merge PR #1995: Persist the AutoSave preference across restarts (#1901) 2026-10-22 02:35:12 +00:00
aiongg 8daecc7777 Integration: #1975 remove stray empty file '=560' 2026-10-22 02:35:12 +00:00
aiongg 9e56ed3803 Merge PR #846: Closing the window asks to save unsaved workbooks 2026-10-22 02:35:11 +00:00
aiongg 4cdffe455f Merge PR #1728: Use the platform clock for UI timing on the web
Conflicts: docs/ui-parity.md (header change text and change log: union of both sides)
2026-10-22 02:35:11 +00:00
aiongg 2c10a8da46 Merge PR #759: Names: a name over several ranges reads its top-level commas as unions
crates/formula/src/lib.rs: union pub use list (MAX_FORMULA_LEN and parse_name).
crates/engine/src/cmd/formulas.rs: keep both new tests.
2026-10-22 02:35:11 +00:00
aiongg 15dfbef548 Merge PR #2195: calc: cap LINEST-family regressor matrices (#2177) 2026-10-22 02:35:10 +00:00
aiongg cacb91d29d Merge PR #1520: fix #1443: Ctrl+Q no longer closes the window outside macOS 2026-10-22 02:35:10 +00:00
aiongg 708c82cf2c Integration: #1059 delete_areas accepts a single row or column
home.deleteRows {rows:"2"} and home.deleteColumns {cols:"B"} failed with
"not a range"; delete_areas now parses with parse_lines. The PR's test
covers both.
2026-10-22 02:35:09 +00:00
aiongg c8e0323cf3 Merge PR #1975: Select precedent/dependent cells with Ctrl+[ / Ctrl+] (#1865 part 1) 2026-10-22 02:35:09 +00:00
aiongg 56cccd1f64 Merge PR #2194: calc: bound SUBTOTAL/AGGREGATE input work (#2175)
docs/function-parity.md: status line takes this PR's change text; both history rows kept, newest first.
2026-10-22 02:35:07 +00:00
aiongg 93517ccf51 Merge PR #1500: Fix number-format palette colours in grid and PDF output
crates/engine/src/cmd/print.rs: kept both appended tests
docs/ui-parity.md: kept HEAD header line, both change rows
2026-10-22 02:35:06 +00:00
aiongg 1f9696d013 Merge PR #1958: Translate and standardizes UI error feedback by locale 2026-10-22 02:35:06 +00:00
aiongg 3ad6034f33 Merge PR #1924: Grid: row and column heading labels grow with the zoom
crates/ui-egui/src/grid.rs: kept both helper fns (painted_halign, heading_font_size) and both tests.
2026-10-22 02:35:06 +00:00
aiongg ad490a1393 Merge PR #1735: Analysis ToolPak: moving-average standard errors use a running sum (#1043) 2026-10-22 02:35:05 +00:00
aiongg cbf13a4c1d Integration: #1236 keep the sys-cjk-han-zh sample string at 页面布局 客户端配置 2026-10-22 02:35:05 +00:00
aiongg f8b7547344 Merge PR #1709: Rows: a big font isn't counted twice when the sheet's default height already fits it 2026-10-22 02:35:02 +00:00
aiongg 62c70242ce Merge PR #1672: Keep overflow text visible when its source cell scrolls out of view 2026-10-22 02:35:02 +00:00
aiongg 51401a382c Merge PR #1534: engine: a cut takes the source's notes, hyperlinks and merged areas with it 2026-10-22 02:35:01 +00:00
aiongg 6f8b6c33e0 Integration: #1225 write a dxf's border before its protection
Schema order is font, numFmt, fill, alignment, border, protection.
2026-10-22 02:35:01 +00:00
aiongg 36763fb663 Merge PR #1236: Fix native CJK fallback paths in Flatpak
Conflicts:
- contributors/people.toml: both new contributors kept
- crates/ui-egui/src/theme.rs: LINUX_CJK_DIRS has the Flatpak mounts plus the chromeos entry (22 entries)
- docs/localization-parity.md: both status lines, text and history rows kept
2026-10-22 02:35:01 +00:00
aiongg 8fa5035b21 Merge PR #1277: Zoom dialog: the reference app's layout, current zoom pre-selected, Fit selection (#930) 2026-10-22 02:35:00 +00:00
aiongg 013f60a8b6 Merge PR #1514: Fix scalar INDEX/MATCH XLSX export
Conflicts:
- docs/file-format-parity.md: kept main's header and log rows, added this PR's change phrase and row once
2026-10-22 02:34:59 +00:00
aiongg 32faa4f1c6 Merge PR #1661: Merged cell with Wrap Text: auto-fit skips it, so the text draws and the row keeps its default height 2026-10-22 02:34:59 +00:00
aiongg ee54646258 Merge PR #1372: Edit: text that would parse as a number or formula keeps its quote prefix in the editor (#1362) 2026-10-22 02:34:59 +00:00
aiongg 8d1263a047 Merge PR #1618: Grid: a cell with line breaks and Wrap Text off draws the whole string on one line 2026-10-22 02:34:59 +00:00
aiongg 0f27cef4d6 Merge PR #1364: engine: keep case-insensitive replace from dropping text when folding shifts byte boundaries 2026-10-22 02:34:59 +00:00
aiongg 6139ef7537 Merge PR #1724: Tables: add the missing Resize Table button (#935) 2026-10-22 02:34:58 +00:00
aiongg 6735e9e7c0 Integration: #451 qualify the run-stop row through value_ref
A stored cell with no value and no formula falls through to spill in
value_ref; the scan now asks value_ref for the row where the stored run
stops. Docs corrected; tests cover a style-only stored cell over a spill
value.
2026-10-22 02:34:58 +00:00
aiongg 3eda63d5e5 Merge PR #1706: Reject table.resize when the new range overlaps another table 2026-10-22 02:34:58 +00:00
aiongg 604d85e29c Merge PR #1531: xlsx: keep a table's filter criteria when the sheet AutoFilter coincides with it 2026-10-22 02:34:58 +00:00
aiongg 9f03d2cd1b Merge PR #1434: Refuse table names that clash with defined names or R1C1 references
Conflicts: crates/engine/src/cmd/insert.rs (both appended a test at the end of tests; kept both)
2026-10-22 02:34:58 +00:00
aiongg 040d778182 Merge PR #1375: Header menus: Format Cells… for the selected rows or columns 2026-10-22 02:34:56 +00:00
aiongg ccbf28fe63 Merge PR #1589: Web: keep preferences such as dark mode between visits
docs/ui-parity.md: header line kept as main's (newer); revision-history row for the web preferences added once.
2026-10-22 02:34:56 +00:00
aiongg e9479d2155 Merge PR #1360: engine: keep AutoFill text series from panicking or wrapping on i64 overflow 2026-10-22 02:34:56 +00:00
aiongg 675d96ef17 Merge PR #1373: Protection: Protect Sheet entries unprotect, selectLockedCells enforced (#1346) 2026-10-22 02:34:56 +00:00
aiongg 5276fce027 Merge PR #1363: Context menu: the right-click that opens it doesn't close it 2026-10-22 02:34:56 +00:00
aiongg 0cc4798ca9 Merge PR #1650: fix(ets): avoid repeated scans while interpolating gaps
docs/function-parity.md: status line takes this PR's change text with the newer review date; both history rows kept, newest first.
2026-10-22 02:34:55 +00:00
aiongg 5b2a67c236 Merge PR #1252: Dialogs move by their title bar instead of staying pinned to the centre (fixes #1250)
docs/ui-parity.md: status line combined (newest date, both change notes)
2026-10-22 02:34:55 +00:00
aiongg 696555f484 Merge PR #1332: Keyboard: Alt+= no longer starts an edit with the = it types 2026-10-22 02:34:54 +00:00
aiongg 0087afce8a Merge PR #1230: Journal: cap by serialized size and keep a recording's start through trims
crates/engine/src/tests.rs: kept both sides' new tests (csv_as_text test from main, the two journal tests from the PR).
2026-10-22 02:34:53 +00:00
aiongg 240e773e17 Merge PR #1905: Keep the in-cell editor at the cell's height for one line
crates/ui-egui/src/grid.rs: kept 1974's wrap branch; 1905's one-line height rule applied only in the non-wrap branch.
2026-10-22 02:34:53 +00:00
aiongg 38fb979498 Merge PR #1329: Slicer: find a named table on any sheet
Conflicts: crates/engine/src/tests.rs (both appended a test at the same spot; kept both)
2026-10-22 02:34:53 +00:00
aiongg 93de710d28 Merge PR #1306: Show dynamic array source formulas read-only in spill cells
docs/ui-parity.md: kept HEAD's header line, both change rows (newest first)
2026-10-22 02:34:52 +00:00
aiongg 78225db6f8 Merge PR #1108: Refuse a cut-paste whose destination does not fit instead of dropping cells (#997) 2026-10-22 02:34:52 +00:00
aiongg 0b573362bc Merge PR #1059: rows/cols parameters: accept a single row or column 2026-10-22 02:34:51 +00:00
aiongg 02640d6626 Merge PR #1495: XLSX: preserve carriage returns in saved text
Conflicts:
- crates/xlsx/src/xml.rs: kept both appended tests (surrogate pairs, carriage returns)
- docs/file-format-parity.md: kept main's header and log rows, added this PR's change phrase and row once
2026-10-22 02:34:51 +00:00
aiongg 02e1828c65 Merge PR #995: XLSX export: a picture shared by several sheets is one media part
Conflicts:
- crates/xlsx/src/tests/mod.rs: both mod lines kept
- docs/file-format-parity.md: header keeps main's change list plus the #985 entry
2026-10-22 02:34:51 +00:00
aiongg 7045146342 Merge PR #1550: Open the Insert Chart dialog from Recommended Charts instead of inserting a chart (#1399) 2026-10-22 02:34:50 +00:00
aiongg 90cdb12fc6 Merge PR #1470: fix(solver): commit solutions and reports atomically 2026-10-22 02:34:49 +00:00
aiongg ee6dead310 Merge PR #1057: Flash Fill: read the text cells show 2026-10-22 02:34:49 +00:00
aiongg 49328f80c0 Merge PR #1381: engine: rebuild cell-store bands and counts when importing a JSON workbook 2026-10-22 02:34:49 +00:00
aiongg 0d741d9756 Merge PR #1510: Calc: a dynamic array of one value still anchors a spill range 2026-10-22 02:34:47 +00:00
aiongg 9a22d1e628 Merge PR #1548: Focus the Find what field when Find and Replace opens (#1518) 2026-10-22 02:34:46 +00:00
aiongg a512a6a8a7 Merge PR #1117: Fix table without header: re-point references when the data moves down (#1030) 2026-10-22 02:34:46 +00:00
aiongg 2a3ea02e69 Merge PR #1526: functions: NETWORKDAYS.INTL accepts the all-weekend string "1111111" 2026-10-22 02:34:46 +00:00
aiongg 806a439efa Merge PR #1507: Read securities function dates in the workbook's date system 2026-10-22 02:34:46 +00:00
aiongg 8319587046 Merge PR #1215: File: Save a Copy asks where to save a bare name instead of writing it to the working directory 2026-10-22 02:34:45 +00:00
aiongg 4f7111941e Merge PR #951: fix(fill): respect the workbook date system in monthly AutoFill
docs/ui-parity.md: kept the newer header line, kept both history rows.
2026-10-22 02:34:45 +00:00
aiongg 116cc8697a Merge PR #1371: Keep Middle/Bottom vertical alignment for wrapped text taller than its row 2026-10-22 02:34:45 +00:00
aiongg 00fd1e7a6f Merge PR #1428: Recalc: formulas reading an array that loses a spill conflict see #SPILL!
ROADMAP.md: union of both sides (header change text combined; table and progress-log rows kept once each).
2026-10-22 02:34:45 +00:00
aiongg 011ae7266d Merge PR #1469: Let GROUPBY and PIVOTBY take a LAMBDA as their function 2026-10-22 02:34:45 +00:00
aiongg 4fd7657508 Merge PR #1068: Ribbon: more currency symbols in the Accounting Number Format menu
crates/ui-egui/src/ribbon.rs: kept main's single_area/whole_lines helpers and the PR's accounting_symbol_tests module (both appended at end of file).
2026-10-22 02:34:45 +00:00
aiongg 941da33355 Merge PR #1432: Stop BINOM.INV/CRITBINOM hanging when trials exceed 2^53 2026-10-22 02:34:45 +00:00
aiongg 522b8a1f42 Merge PR #1225: XLSX: keep a dxf's protection, font size and face, and the font parts it turns off 2026-10-22 02:34:44 +00:00
aiongg d34e3db0b8 Merge PR #949: Sheet tabs: New sheet (+) right after the last tab, the strip capped at 60%
Conflicts: docs/ui-parity.md (header change text, sheet tabs row and change log: union of both sides)
2026-10-22 02:34:44 +00:00
aiongg e51bc1ab70 Merge PR #1136: Grid: per-frame work no longer scales with the whole sheet (#1098) 2026-10-22 02:34:44 +00:00
aiongg 8e931b24b4 Merge PR #898: UI: File › Account shows the version, platform and licence, with an About GridCraft button
crates/ui-egui/tests/file_menu.rs: kept both tests (translated headings, account/About).
2026-10-22 02:34:43 +00:00
aiongg 7f9cf33bfd Merge PR #632: Header & Footer dialog opens on the saved header and footer
crates/ui-egui/tests/interaction.rs: keep both new tests
2026-10-22 02:34:42 +00:00
aiongg a3baf552a1 Merge PR #1281: N() converts a computed array element by element, so it works inside SUMPRODUCT
crates/calc/src/tests.rs: kept both tests (union).
2026-10-22 02:34:42 +00:00
aiongg 91af0c60d6 Merge PR #952: Ribbon: Font Size is a box you type into, the sizes behind its arrow
contributors/people.toml: kept both new entries
2026-10-22 02:34:40 +00:00
aiongg 7bf0c8dbd7 Merge PR #1249: Headers: mark hidden columns and rows, split cursor and double-click to unhide (#1247) 2026-10-22 02:34:39 +00:00
aiongg 4ec2de2501 Merge PR #1248: Opening an XLSX with a part over 512 MB says how to get the data in 2026-10-22 02:34:39 +00:00
aiongg 2cbd39ca8d Merge PR #1223: Sheet tabs: right-clicking a tab activates its sheet 2026-10-22 02:34:39 +00:00
aiongg 2c73c2b18f Merge PR #1214: Grid: an armed Format Painter pastes onto the next cells clicked or dragged 2026-10-22 02:34:39 +00:00
aiongg 1f1be405c1 Merge PR #1427: Recalc: an anchor's new formula no longer reads its old spill area 2026-10-22 02:34:37 +00:00
aiongg 4434e142fb Merge PR #1380: xlsx: keep every property of the default cell format when importing a file 2026-10-22 02:34:37 +00:00
aiongg ccc0e8cfe4 Merge PR #885: Row, column and freeze commands act on the sheet they are given 2026-10-22 02:34:36 +00:00
aiongg c4389fcacb Merge PR #701: Keep sheet navigation on visible worksheets at workbook boundaries 2026-10-22 02:34:36 +00:00
aiongg cedaa46916 Merge PR #1156: XLSX: [Content_Types].xml is the first zip entry, workbook parts precede document properties, so libmagic detects the file type (#1090) 2026-10-22 02:34:36 +00:00
aiongg 947930e889 Merge PR #584: Refuse inserting cells that would push values off the worksheet (#580) 2026-10-22 02:34:36 +00:00
aiongg c470450eb3 Merge PR #1091: i18n: add Polish (pl) interface, full catalog (970 labels) 2026-10-22 02:34:35 +00:00
aiongg ed4b4e1ca7 Merge PR #663: i18n: Turkish (tr) interface language 2026-10-22 02:34:35 +00:00
aiongg 0e077cfd88 Merge PR #1974: In-cell editor: a Wrap Text cell edits wrapped at the column width (#1970) 2026-10-22 02:34:35 +00:00
aiongg f55cbcd061 Merge PR #1237: BAHTTEXT: a final one after the millions reads เอ็ด 2026-10-22 02:34:35 +00:00
aiongg bd4c875225 Merge PR #884: Conditional formats: hex colours in a custom style, and no more silently empty or never-matching rules 2026-10-22 02:34:35 +00:00
aiongg 8a7f0348b6 Merge PR #1120: Open notice: do not promise to keep macros that were dropped (#1024) 2026-10-22 02:34:35 +00:00
aiongg 8041fbfc22 Merge PR #451: perf: fill-handle double-click scans a dense neighbour column ~2.4x faster 2026-10-22 02:34:35 +00:00
aiongg 91be133965 Merge PR #1114: Read date text arguments in the workbook date system (#1038) 2026-10-22 02:34:35 +00:00
aiongg 34de196c9f Merge PR #1119: Fix xlsx: combine escaped UTF-16 surrogate pairs when decoding strings (#1025) 2026-10-22 02:34:35 +00:00
aiongg a0a4b6a7d0 Merge PR #1105: Fix row keys merging distinct text rows in UNIQUE and grouping (#1012) 2026-10-22 02:34:35 +00:00
aiongg 2c22070a62 Merge PR #1060: LAMBDA helpers: #CALC! for nested arrays, #VALUE! for wrong parameter counts 2026-10-22 02:34:35 +00:00
aiongg 307b25dc92 Merge PR #488: Build: the ui-egui build script reads CARGO_MANIFEST_DIR when it runs 2026-10-22 02:34:34 +00:00
aiongg a48f418a77 Merge PR #831: i18n: test that translations keep the shape of their English 2026-10-22 02:34:34 +00:00
aiongg df39416e36 Merge PR #1115: Cap CSV import fields per record at the worksheet column limit (#1037) 2026-10-22 02:34:34 +00:00
aiongg 340ce131ef Merge PR #1340: calc: calculate long dependency chains with bounded stack 2026-10-22 02:34:33 +00:00
aiongg 136a8fa07e Merge PR #1337: Selection: whole columns and rows don't widen to merged cells 2026-10-22 02:34:33 +00:00
aiongg ec5e939677 Merge PR #1104: xlsx: keep the first group's series kind when importing a combination chart (#1014) 2026-10-22 02:34:33 +00:00
aiongg a4c53ad020 Merge PR #1061: Recalc: an array that spills as before doesn't requeue its dependents 2026-10-22 02:34:33 +00:00
aiongg 954f30f9f4 Merge PR #1058: Format Cells: take the documented camelCase style keys 2026-10-22 02:34:33 +00:00
aiongg f8f7193f52 Merge PR #1084: Data validation: a list typed without quotes is saved as a quoted list, and an unknown type is refused 2026-10-22 02:34:32 +00:00
aiongg 0288ad2f31 Merge PR #630: Cap queued pointer events from ui.click/ui.drag control requests (#525) 2026-10-22 02:34:31 +00:00
aiongg 9aed6fb534 Merge PR #1102: CSV export: check the size cap before each field, not after the row (#1023) 2026-10-22 02:34:31 +00:00
aiongg f295fa0947 Merge PR #629: Make check --json list warning messages instead of a count (#526) 2026-10-22 02:34:30 +00:00
Higgs32584andClaude Opus 5.5 06b7d09a7e clipboard: drop wall-clock timing from span test (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:28:47 -04:00
Higgs32584andClaude Opus 5.5 5392d95bf8 scripts: require workbook trust before running embedded scripts (#2178)
A workbook opened with GridCraft scripts (`_sc_scripts`) starts untrusted:
automate.runScript refuses them until the user trusts the workbook for the
session (Macros dialog > Trust Scripts, i.e. automate.trustScripts) or the
caller passes `trusted: true` for that one run. Workbooks without scripts,
and scripts written in the session, are unaffected.

Whatever the trust, a script whose steps reach outside the workbook
(file.*, link.*, or any `path` param: Get Data, From Text/CSV, Insert
Picture) is refused before any step runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:13:13 -04:00
Higgs32584andClaude Opus 5.5 b817c04a9b clipboard: bound HTML colspan/rowspan expansion (#2171)
Clipboard HTML is read only as far as the pasted text's shape (clamped
to the sheet) and at most 1,000,000 cells; colspan/rowspan are clamped
to the sheet extent before expanding, and rowspan now keeps later rows
in their columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:06:51 -04:00
Higgs32584andClaude Opus 5.5 836b269806 calc: cap array broadcast result size (#2182)
zip2 (binary operators, TEXT), zip3 (array IF) and MAP now compute the
broadcast cell count with checked multiplication and return #NUM! when it
exceeds MAX_CELLS, instead of allocating (abort) or wrapping on wasm32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:27:26 -04:00
Higgs32584andClaude Opus 5.5 5b908c04f8 core: iterative number coercion for nested parentheses (#2180)
Parenthesised negatives were parsed by recursing once per level, so runtime
text such as REPT("(",32767)&"1"&REPT(")",32767) overflowed the stack. Strip
the parentheses in a loop, toggling the sign per level; results are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:15 -04:00
Higgs32584andClaude Opus 5.5 be94b011c5 calc: bound SUBTOTAL/AGGREGATE input work (#2175)
Every area, array and value of one SUBTOTAL/AGGREGATE call is charged against MAX_CELLS before any cell is read; past it the call returns #NUM!. A value stored in XFD1048576 no longer makes SUBTOTAL(9,A:XFD) read ~17 billion cells into a vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:10:00 -04:00
Higgs32584andClaude Opus 5.5 e1b8aad143 calc: cap LINEST-family regressor matrices (#2177)
LINEST, LOGEST, TREND and GROWTH built k x k normal-equation matrices
(plus two copies in sym_inverse, O(k^3)) with k taken straight from the
known_xs shape, so one formula could allocate gigabytes and abort on open.
More than 255 predictors is now #VALUE!, and more than 1e9 rows x k^2
multiply-adds is #NUM!, both checked before any allocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:30 -04:00
Dylan M. Taylor 912b817abe security: enforce HTTPS on curl downloads (SonarCloud S6506) (#2188)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 19:36:04 -04:00
Francisco Herrera c3184b74f9 Address CodeRabbit review: borrow the workbook and keep both swatch outlines (#2148) 2026-10-21 15:16:30 -05:00
Dylan M. Taylor 8ae67c5534 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:41 -04:00
Dylan M. Taylor eb48beffb5 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:00 -04:00
Francisco Herrera a9223eabf8 Table Design: draw the Table Styles gallery in the ribbon (#2148) 2026-10-21 14:44:47 -05:00
Ufuk Efe 7ba381d9ae i18n: Turkish (tr) interface language 2026-10-21 21:22:19 +03:00
Aimar Mendez 0812d3aee5 PivotTable Fields pane: scan the source once per change, not every frame
The pane ran `pivot.fields` on every frame while the active cell was in a
PivotTable report. That command reads the whole source list and collects
distinct labels for every field, so a 250,000 x 20 source made each frame
cost ~2.4 s and the window looked frozen (#2113).

Keep the answer in GridState, keyed by (doc uid, revision, pivot), the way
Page Break Preview keeps its pages. Any undoable edit, undo or redo bumps
the revision and drops it.

Known limit: Calculate Now / Calculate Sheet don't bump the revision, so
volatile formulas in the source can leave the tooltip samples stale until
the next edit; the field list itself only changes through undoable edits.

VERIFIED
- New test pivot_fields_pane_reuses_its_field_list_until_the_workbook_changes
  fails without the change and passes with it.
- Release build, 250,000 x 20 synthetic source, pane open (ui.inspect):
  main ~2,400 ms per frame throughout; this branch 2,528 ms for the first
  frame, then 0.4-0.9 ms.
- `cargo xtask ci` passes all 8 steps on macOS with the separate clippy
  fix from #2141 applied locally (main fails clippy on macOS without it).

NOT IN THIS CHANGE
- The report is still empty after opening a file saved with
  refreshOnLoad="1" (second part of #2113).
- `pivot.fields` itself is unchanged; the first frame still pays one scan.
2026-10-21 19:03:36 +01:00
Brandon ThomasandClaude Opus 5.5 2ebdf164c2 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:57:26 -04:00
midasdf b092633561 Print: Japanese Envelope #4 is 90×205 mm and add B6 (ISO) to the Size menu
長形4号 is a 90×205 mm envelope (90×225 mm is 長形40号, a different one), so
the paper table gave it a height 20 mm too tall — and XLSX paper id 74, the PDF
MediaBox and the print preview all followed it. Use 205 mm (581.10 pt) and pin
the corrected size with a dedicated assertion; the ROADMAP progress row is
corrected too. 長形40号 is not added: it is a different envelope.

The Page Layout ▸ Size menu stopped at `B5 (ISO)` and never offered
`B6 (ISO)`, which the engine and the command docs support; add it.
2026-10-22 00:24:30 +09:00
midasdf f91f7c05d4 Print: JIS B4/B5/B6 and Japanese paper sizes
Japanese Excel's B4/B5/B6 are the JIS sizes (257×364, 182×257 and 128×182 mm),
but Page Setup's `b4`/`b5` were the ISO ones (250×353, 176×250): a Japanese B5
worksheet printed 8 mm narrow. `known_paper` now reads `B4`/`B5`/`B6` as JIS and
keeps the ISO sizes reachable as `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. This also
fixes the XLSX round-trip, whose `paperSize` ids 12/13 are the JIS sizes.

Adds the Japanese papers Page Setup lacked: Japanese Postcard (はがき,
100×148 mm), Japanese Double Postcard (往復はがき, 148×200 mm), Japanese
Envelope #3/#4 (長形3号/4号, 120×235 and 90×225 mm), Japanese Envelope Kaku
#2/#3 (角形2号/3号, 240×332 and 216×277 mm), A6, and aliases such as
`postcard`, `chou #3`, `kaku #2`; the xlsx paper table gains ids 42 (ISO B4) and
43/69–74/88 so these names survive save and reopen (the ISO B5/B6 names have no
ECMA-376 paper id and stay engine-side).

The pageSetup/pageLayout.size parameter docs list the names, the Page Layout
Size menu lists them, and tests pin the point sizes, the PDF MediaBox for A4
(unchanged), B5, postcard, double postcard and kaku #2, landscape and
fit-to-page, the unknown-name Letter fallback, and the XLSX round-trip.

Docs: docs/target-app-parity.md print row + revision history, and ROADMAP.md
progress log, dated 2026-10-21.
2026-10-21 23:38:26 +09:00
midasdf 97aa04dc80 i18n(ja): add the §11 revision row and align the docs header dates
The Japanese catalog's ribbon labels the review flagged are already in the head
("Save (⌘S)" is 上書き保存 (⌘S), "Insert\nSlicer" is スライサーの\n挿入, and the
catalog test asserts the new value), so only the docs needed work: docs/gaps.md
had no revision row for the §11 Japanese-catalog update. Add one dated
2026-10-20, and set the "Last updated" header of ROADMAP.md, docs/gaps.md and
docs/localization-parity.md to the same date as their new rows. Every existing
revision row keeps its original date.
2026-10-21 22:51:09 +09:00
midasdf e42ebf9311 XLSX: keep a phoneticPr that has no rPh runs, and document the limits
si_body returned as soon as no valid run was written, so a shared string with a
<phoneticPr> and no <rPh> — a shape §18.4.8 allows — lost its properties on
save even though the reader keeps them. The runs and the properties are now
written independently: valid runs as before, then the valid <phoneticPr> when
the workbook has one; with neither, the <si> is exactly the plain t_el output.
property_only_shared_string_survives_open_and_save covers read, save and
reopen, and the si_body damaged-data test now expects the property-only output.

The write-time checks the reader also runs (sb/eb clamped into the UTF-16
length, empty readings skipped, type/alignment filtered through the shared
PHONETIC_KINDS/PHONETIC_ALIGNMENTS) were already in the head; the tests above
pin them, so no further change there.

Docs: the .xlsx row and the revision history now state that the shared string
table holds each text once, so duplicate <si> items with the same text but
different readings keep only the first reading (reported as a warning on open),
and that a <phoneticPr> without runs is kept. Both parity docs' "Last updated"
headers match their 2026-10-20 revision rows; the earlier rows keep their dates.
2026-10-21 22:35:13 +09:00
Lord-Taw 377bb9226e Update i18n.rs
## CI Validation

`cargo xtask ci` completed successfully.

<details>
<summary>Build Warning</summary>

```text
warning: variants `At` and `Unknown` are never constructed
   --> crates\ui-egui\src\backstage.rs:416:5
```

This warning appears to be pre-existing and is unrelated to the changes introduced in this PR. As it falls outside the scope of the current work, no changes were made to address it.

</details>

The build completed successfully, including the `wasm32-unknown-unknown` target checks.

```text
wasm32-unknown-unknown check (gridcraft-calc, gridcraft-chart, gridcraft-core, gridcraft-engine,
gridcraft-formula, gridcraft-functions, gridcraft-model, gridcraft-numfmt,
gridcraft-pdf, gridcraft-web, gridcraft-xlsx): ok
```

<details>
<summary>CI Parity</summary>

```text
CI summary:
  ok    fmt
  ok    clippy
  ok    test
  ok    assets
  ok    layers
  ok    wasm
```

</details>
2026-10-21 10:24:12 -03:00
Nikolai Nyegaard c812881fa9 Backstage: list the languages alphabetically by their own name
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
2026-10-21 12:01:41 +02:00
Viorel ToaderandClaude Opus 5.5 c26290cd04 Show form notes that contain a pipe as they are
tr reads | as a context separator, so translating the Open Hyperlink note
cut a URL with a pipe down to the part after it. Notes carry user data
(URLs, file and sheet names); one with a pipe is now drawn verbatim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-21 11:13:57 +03:00
Viorel ToaderandClaude Opus 5.5 43e64c50a2 Romanian: print settings and status, five form labels; Page Setup… matches its dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-21 10:57:20 +03:00
Viorel ToaderandClaude Opus 5.5 8e38fc21fa Romanian: feminine stângă/dreaptă for the margin labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-21 10:57:20 +03:00
Viorel ToaderandClaude Opus 5.5 028a02ba72 Romanian (ro) interface language
Adds Romanian as an interface language: crates/ui-egui/src/i18n/ro.rs with 970 labels covering
every key of the other catalogs, registered in Language (switcher, app.language.set, ro-* system
locales). Context keys translate without their prefix; the File › Options kittest covers ro.
English formulas and formats; no proofing.

The catalog is an AI-assisted draft written from the meaning of the English labels; a native
speaker has not yet reviewed it line by line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-21 10:57:20 +03:00
Viorel ToaderandClaude Opus 5.5 29adb19872 Translate form-dialog field labels, choices and notes
Page Setup, Insert and the other form dialogs drew their field labels in
English in every interface language, though their titles and OK/Cancel
were translated and most catalogs already carry the labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-21 10:57:10 +03:00
Dylan M. Taylor 37e41424ed Merge pull request #1620 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:22 -04:00
Gert Pellin a35d241caf XLSX: a note keeps its box's place and size through open and save (#1608)
The reader took only <x:Visible/> from a note's VML shape and vml_xml
wrote every note with a fixed anchor (two columns right of the cell,
four rows tall) and a 108 x 59.25 pt style, so a plain open and save
moved and resized every note box Excel had stored.

Comment gets an optional NoteBox: the <x:Anchor> corners relative to the
note's cell (so the box follows the note when rows or columns move) and
the shape's width and height. The writer puts it back, clamped to the
sheet; notes made here, and boxes equal to the default, keep the
default box.
2026-10-21 09:20:06 +02:00
kezmdorandClaude Opus 5.5 d1ab0ea85d Merge upstream main (0.9.0) into fix/2030-incomplete-formula-error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:16:47 +02:00
chessiveandClaude Opus 5.5 a4a36e8460 Fonts: a cell font named in another language matches its installed font (fixes #1898)
Korean Excel saves a cell's font under its Korean name (맑은 고딕 for
Malgun Gothic), but the font scan kept one family name per face, the
English one, so those cells were drawn in the default face. Each face now
keeps its typographic and legacy family names in every language, and a
workbook font is bound under the name the workbook uses, which is the
name cell_family looks up. A name two families share goes to the family
whose English name it is, else to the first face by path, so the scan's
directory order no longer decides it. Names bound under two spellings
share one copy of the font data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:45:39 +09:00
ZaxaerithandCodex 1abb771f58 docs: fix Trunk website link (#2076)
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 12:43:15 +08:00
dantebertuzziandClaude Opus 5.5 68f956a410 Number Stored as Text: green indicator, warning button and Convert to Number (#1994)
Numbers stored as text are skipped by SUM and friends, and GridCraft gave
no hint. Like Excel's background error checking, a constant whose text
would be a number if typed now gets a green triangle in its top-left
corner; with such a cell active, a warning button beside it opens a menu
naming the problem with Convert to Number, which converts every flagged
cell in the selection as one undo step.

- core: `parse::number_stored_as_text` (signs, thousands separators,
  currency, percent, exponent, one parenthesised negative). Dates, times
  and fractions kept as text are not flagged, as in Excel.
- engine: `cmd::formulas::number_stored_as_text(cell)` (constants only,
  not formula results) and `formulas.convertToNumber {range?}`, which
  keeps each cell's format.
- ui: the triangle in the grid and the warning button with its menu;
  pt-BR and German strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:39:30 -03:00
dantebertuzziandClaude Opus 5.5 bdc0552a92 Dialogs: OK and Cancel in a row, so form dialogs fit their content
`ok_cancel` put the buttons in a right-to-left layout straight in the
window. That layout takes all the height it is offered, so every form
dialog (Greater Than…, New Name, Remove Duplicates, …) grew to the
screen's height with OK and Cancel floating halfway down. The buttons
now sit in a horizontal row as tall as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:38:34 -03:00
dantebertuzziandClaude Opus 5.5 cacb620fec Paste Special: Excel's two-column layout, translated, Paste Link button
The dialog was a generic form: one column of fifteen radio buttons and
three check boxes, taller than the window, with English labels (the form
didn't run them through `tr`). It is now laid out as Excel's: Paste and
Operation each in two columns inside a frame, Skip blanks and Transpose
side by side under them, and Paste Link as a button that pastes
references to the copied cells at once. The letter shortcuts keep
working: the options still come from the dialog's fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:38:17 -03:00
Vedran DobosandClaude Opus 5.5 226c055fb7 fix(ui): Esc and Enter close a data validation alert, and the keyboard goes back to the entry (#2089)
A data validation alert is the general message box, drawn over the cell
editor, which stays open so the entry can be fixed. The editor read Enter and
Esc first: the first Esc cancelled the edit behind the alert and only the
second closed it, and Enter tried the entry again so the alert came back. The
editor now skips its own key handling while a message is open over it, so Esc
and Enter close the alert (like its OK button). The message box also had the
keyboard focus and didn't give it back, so typing went nowhere until the cell
was clicked; closing it now asks the open editor for focus again.

Tests: ui-egui tests/validation_alert.rs
esc_closes_a_validation_alert_and_leaves_the_entry and
enter_closes_a_validation_alert_and_leaves_the_entry (both fail on main, and
without the focus change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:51:17 +02:00
dantebertuzziandClaude Opus 5.5 1788c5a771 Grid: paint the scrollbar's column beside the column headings
The vertical scrollbar's track starts below the column headings (and any
frozen rows), and nothing painted the bar's column above it, so dark mode
showed a white square at the top-right corner of the grid. The whole
column now takes the track colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:12:20 -03:00
dantebertuzziandClaude Opus 5.5 83a41dcd68 Right-click on a chart opens the chart's menu, not the cell's (fixes #2067)
The grid's right-click handler never hit-tested objects, so right-clicking
a chart opened the menu of the cell underneath it. And since any button's
drag start grabbed an object, a right press that moved a little started
dragging the chart and no menu opened at all.

Right-clicking a chart, picture, shape or slicer now selects it and opens
its own menu (Delete; for charts also Select Data… and Format Chart
Area…). Only the primary button moves objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:56:04 -03:00
Eduardo GarciaandClaude Sonnet 5.5 41cb19fd6d Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> 2026-10-20 18:39:49 -03:00
Eduardo GarciaandClaude Sonnet 5.5 eb30ab8f3f Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> 2026-10-20 18:39:49 -03:00
Mirza Bilal feac1679a1 HTML export: place pictures and charts at their anchors
Save as Web Page wrote only the cell table, so pictures and charts on a sheet
were dropped. A sheet that has either now keeps its own geometry and draws them:

- The table gets the sheet's column widths and row heights (`table-layout:fixed`,
  a `<colgroup>`, a height per row); hidden columns and rows take no room and
  merged-cell spans count only visible lines.
- A picture is an `<img>` with a `data:` URI, a chart is inline SVG built from
  the chart crate's drawing primitives (titles, axes, legends and data labels
  included), each absolutely positioned at its anchor cell plus offset.
- Sheets without pictures or charts write byte-identical HTML.

Adds contributors/people.toml entry for bilalmughal.
2026-10-21 01:35:58 +05:00
NetterSilverandClaude Haiku 5.5 f29ac7d9ef Use scientific notation when number_to_text rounds up to 1e15
Found while testing crates/core/src/value.rs:354-367

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:16:31 +00:00
Stefan FransenandClaude Fable 5.1 c8a87f57dd i18n: Dutch (nl) interface language
Parity doc: state the Dutch coverage precisely (the shared key set the
German, French and Czech catalogs cover, not the larger Portuguese one).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 18:25:41 +02:00
Stefan FransenandClaude Fable 5.1 45c91ede21 i18n: Dutch (nl) interface language
Unify the Freeze family on "blokkeren" (Excel's Dutch wording).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 18:16:28 +02:00
Stefan FransenandClaude Fable 5.1 60e113bcd7 i18n: Dutch (nl) interface language
Adds a Dutch catalog (`i18n/nl.rs`, 518 labels) covering every key the
other catalogs translate: ribbon, context menus, File view, dialog
titles and the Options page. `nl-NL` and `nl-BE` locale tags resolve
to Dutch. Updates localization parity and the roadmap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 18:10:09 +02:00
Brandon ThomasandClaude Opus 5.5 68e58a2eae Skip CodeRabbit reviews on Dependabot PRs (#2057)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:39:59 -04:00
Harrison BoothandClaude Sonnet 5.5 ed48389c46 Formulas: compare right operands only after the left spine of a chain matches
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:38:12 -05:00
bfentyandClaude ad0ce61b8a fix(engine): strip one leading = when validating CF formulas
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 12:15:27 +00:00
bfentyandClaude 24ee1e4db7 fix(engine): count word-wrapped lines for automatic row height
Closes #1969

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 11:36:12 +00:00
quantmind-br bc58b2773a Document the functions this PR touches 2026-10-20 08:25:18 -03:00
Mario 2fb880a6d5 Fix WebGL startup on devices with lower GPU limits 2026-10-20 09:30:04 +02:00
RedBearAKandClaude Opus 5.5 4faa3ff098 Row auto-fit: count line breaks only in cells that wrap
auto_row_height grew a row for every line break in a cell's text whether
or not the cell wrapped. Excel counts breaks only when Wrap Text is on:
with it off a multi-line value displays on one line and AutoFit gives a
one-line row. A file whose unwrapped cells hold pasted multi-line text
(the shape generating libraries write) opened with tall rows and nothing
visibly tall in them.

The fast path no longer treats a line break as a reason to measure, and
the measuring path lays text out over lines only for a wrapping cell.
Typing a break still turns wrap on (the Alt+Enter rule in cell entry) and
grows the row, as before.

Test: an unwrapped multi-line cell from a file keeps the default height
on open; wrap on grows the row; wrap off returns it; a typed break wraps
and grows. The open-time fit tests from #833 are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:45:22 -08:00
icsimpiantiandClaude Opus 5.5 4e17be1929 Merge upstream main into italian-translation
Resolve the docs/localization-parity.md conflict: upstream header, both history rows kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:39:09 +02:00
kezmdorandClaude Opus 5.5 cfb0e186ed Formula entry: an incomplete formula's error describes what was typed (fixes #2030)
`=1+` reported "unexpected `)`", although no `)` was typed: entry closes
missing parentheses by appending up to eight `)`, and when every retry
failed it reported the last retry's error. It now reports the error of
the formula as typed ("unexpected end of formula").

`gridcraft_formula::parse_closing_parens` holds the convenience and takes
the parse function, so cell entry and the UI's Spanish formula path share
it instead of each having its own loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:44:09 +02:00
Mouhand-Kaddo 189eb7ae52 M12.AR14: keep the theme's per-script typefaces (<a:font script="Arab">) on save 2026-10-20 09:21:42 +04:00
bmh129andClaude Opus 5.5 132e887efa Linux: time out the desktop theme's portal read
The theme watcher (DesktopTheme) reads the desktop's light/dark choice
from the XDG Desktop Portal over a blocking D-Bus call with no timeout.
zbus waits forever by default, so a portal that accepts the call and
never answers stalled the watcher's thread on its first read, and "Use
system setting" stopped following the desktop for the rest of the
session, even after the portal recovered.

The session connection is now built with a 2 s method timeout (REPLY,
shorter than the 4 s POLL). A timed-out call returns InputOutput, which
read() already handles by dropping the connection, so the next poll
asks again on a fresh one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115HyvML9WnuFRdmtMcLP5t
2026-10-19 23:45:59 -05:00
midasdf ad32e6a847 i18n(ja): translate every dialog, message and menu string the pt-BR catalog covers
The Japanese catalog still lived inline in i18n.rs with its ribbon and widget
labels, so with the interface set to 日本語 the dialog titles and labels, message
boxes, Backstage/File-view items, template names and native menus showed in
English. This moves it to i18n/ja.rs and adds the 545 keys the Brazilian
Portuguese catalog translates beyond it: 943 keys, the coverage pt-BR has
(~43% of ~2,200 strings), in Excel for Mac 日本語版 wording.

CORE keeps the shared reference set every catalog covers, verbatim and in the
same order (the cross-catalog coverage test still points at it); DIALOGS holds
the new strings, and the lookup checks it first. One test parses pt.rs's source
so the two catalogs cannot drift apart again, one checks that every key is
unique across the two lists and every value is translated, and one spot-checks
the high-value strings.

Docs: the Japanese row and the "Where we are" bullet in
docs/localization-parity.md (943, ~43%), a revision row, the ROADMAP Languages
paragraph and progress log, and gaps.md §11.
2026-10-20 13:45:17 +09:00
midasdf 40803e6aca XLSX: keep Japanese phonetic runs (ふりがな) on open and save
xl/sharedStrings.xml can carry phonetic runs (<rPh sb="0" eb="2"><t>ふりがな</t></rPh>)
and a <phoneticPr> beside a string item. GridCraft read only the text and wrote every
string back as <si><t>…</t></si>, so a plain open + save deleted a Japanese workbook's
furigana.

Both are now read — offsets validated against the base text, runs with bad or missing
offsets, sb >= eb or no reading dropped with a warning, and never used to index — and
kept on the workbook as Workbook::phonetics, one entry per string text. The writer
emits them next to the <si> of the same text. The saved table holds each text once, so
two identical texts cannot keep two different readings: the reader keeps the first and
warns. Strings without phonetic data are written byte for byte as before.

Behaviour reference: ISO/IEC 29500-1 §18.4.6 rPh and §18.4.7 phoneticPr (Microsoft
Learn PhoneticRun/PhoneticProperties). Tests: crates/xlsx/src/tests/phonetic_tests.rs
(round trip, plain string, hostile offsets, shared text, unused data, comments and
inline strings). Docs: file-format-parity.md, localization-parity.md.
2026-10-20 13:39:48 +09:00
midasdf 2c6ffe489c XLSX theme: keep the major/minor ea and cs typefaces through open + save
A theme's <a:fontScheme> carries latin, ea (East Asian) and cs (complex
script) typefaces for each of the major and minor fonts. The model held
only the latin ones and the writer emitted <a:ea typeface=""/> and
<a:cs typeface=""/> unconditionally, so a Japanese workbook's theme fonts
(e.g. 游ゴシック, MS Pゴシック) were silently deleted on open + save.

Theme gains major_font_ea, minor_font_ea, major_font_cs and
minor_font_cs (#[serde(default)], so older GridCraft JSON still loads;
empty means the file did not set one). The XLSX reader and writer
round-trip them escaped, and a theme without them writes exactly the
empty attributes it wrote before.

Rendering and font resolution are unchanged: the UI does not consume ea
yet, and wiring it in is a follow-up.
2026-10-20 13:35:52 +09:00
bfentyandClaude 8ff06e215e fix(engine): reject unparseable conditional-format formulas at creation
Closes #1999

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 02:48:58 +00:00
jonathanwxh-cell fc1d629cc5 fix: merge current main into hidden-sheet navigation repair 2026-10-20 10:44:49 +08:00
bmh129andClaude Opus 5.5 27760812de i18n: test that translations keep the shape of their English
The French catalog once shipped with over a hundred translations sitting
on their neighbours' keys ("Close" read "Société"); main has since fixed
them. A catalog shifted that way still covers every key, so the existing
coverage tests don't catch it.

A new test checks every language's translations keep the shape of their
English: the same {placeholders}, and no line break in a one-line label
(the two big ribbon buttons that wrap are exempt). Plus spot checks of
French labels that were once misplaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:38:08 -05:00
Brandon ThomasandClaude Opus 5.5 a2cc4b42ca Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:20:35 -04:00
Lord-Taw 479d1b4b66 Update i18n.rs
´cargo fmt --check´ OK
2026-10-19 23:20:02 -03:00
Harrison BoothandClaude Sonnet 5.5 199e5f8e05 Formulas: test the order walk and map visit nodes; date ROADMAP like the parity doc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 20:56:26 -05:00
Lord-Taw edd6e0cbe8 Atualizar o i18n.rs 2026-10-19 22:27:15 -03:00
Harrison BoothandClaude Sonnet 5.5 4ce8dc55ec Docs: date the long-chains revision entry 2026-10-19
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:59:35 -05:00
Eduardo GarciaandClaude Sonnet 5.5 52ab0e6bfb #1458: formula round-trip test for Excel's sheet-name quoting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 20:55:52 -03:00
Harrison BoothandClaude Sonnet 5.5 b3e0a7e8d3 Formulas: count the leftmost operand of a chain at the same depth
The left end of a flat operator chain is not nesting, so (((...1)))+1 is
accepted whenever (((...1))) is, in Spanish entry and display.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:54:30 -05:00
Harrison BoothandClaude Opus 5.5 2aeb21d4d3 Formulas: flat operator chains as long as Excel allows (#916)
along: =1+1+... with 4,096 operands (8,192 characters, exactly Excel's
limit, which Excel accepts and calculates) overflowed the stack when
entered, because the derived Clone of Expr recursed once per operand
down the chain's left spine. Saving hit the same in the XLSX writer.

- Expr: Clone, PartialEq, walk and map follow the left spine in a loop
  and recurse only into right operands and real nesting.
- XLSX: _xlpm. prefixing and the implicit-intersection scan follow the
  spine too. The scan's depth limit of 64 wrote any chain of more than
  about 64 operands as a dynamic-array formula; such sums are plain
  formulas again (ranges in the chain still get array evaluation).
- Evaluate Formula borrows sub-expressions instead of cloning each
  operator's subtree (quadratic memory on a long chain).
- Spanish formula entry and display no longer refuse chains of more
  than 256 operands; the left spine does not count as nesting.

Tests run on a 1 MiB stack (the Windows main thread) with Excel's
observed results: formula (clone/eq/walk/map/adjust at 4,096), calc
(401 and 4,096 operands, -, *, /, ^, &, %, comparisons, 1,500 refs),
xlsx (long chains written as scalar formulas), engine (cell.set, fill,
copy/paste, insert rows/columns, undo/redo, Evaluate Formula, XLSX save
and reopen), Spanish entry. Each fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:54:30 -05:00
vangel76 d9d4d64645 PR1372: merge main and preserve quote-prefix and CSV regression tests 2026-10-20 00:56:41 +02:00
Dylan M. Taylor 23829d8c67 Merge pull request #1828 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-19 17:12:29 -04:00
Eduardo GarciaandClaude Sonnet 5.5 0ca1554642 docs: sheet-name quoting rows in function and file-format parity (#1458)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 17:36:47 -03:00
Eduardo GarciaandClaude Sonnet 5.5 0b4cc5618f fix #1458: one Excel sheet-name quoting rule for formulas, pivot sources and ADDRESS
Sheet names starting with '.' or reading as R1C1 (R, C, RC, R1C1...) were saved
unquoted, so Excel could not open the file; PivotTable sources and ADDRESS left
cell-like names (A1) bare. The quoting rule, unquoting and sheet splitting now
live once in gridcraft_core::addr and replace the copies in the formula printer,
pivot, ADDRESS, chart, xlsx, Solver, Go To and the Name Box (which now reads ''
as ').

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 17:36:40 -03:00
MarekSpodBiedry 026239458a Formula editing: arrow pointing restarts from the edited cell after typing (#1949) 2026-10-19 22:27:34 +02:00
Maciej Malinowski a74e82ac93 formula: same_name folds case without merging ı and i
Lower-casing then upper-casing made dotless ı equal to i (both become I),
which Unicode's default case folding keeps apart; LET(i,1,ı,2,i) could
return the ı binding. same_name now lower-cases and adds the folds lower
case misses (ß/ẞ -> ss, ſ -> s, ς -> σ). Tests cover ı/i and the Greek
final sigma.
2026-10-19 22:27:21 +02:00
bfentyandClaude 122d3d9a31 feat(formula): Spanish aliases for financial functions (VNA, TIR, PAGO, VA, VF)
Closes #1986

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 20:14:24 +00:00
Maciej Malinowski 553b0bd9f2 ui: language command docs and error list every language
app.language.set's engine.commands docs and its unknown-language error
were written by hand and had fallen behind: the docs missed es, fr and
sr, the error missed de, fr and sr. Both are now built from
Language::ALL (Language::code_list), and tests check every language is
listed in each.
2026-10-19 21:49:23 +02:00
bfentyandClaude 871c4e6c8f perf(engine): scan only the precedent area's stored cells
iter_range walks the sparse store's bands for the area's row range
only, so a chain of N formulas no longer rescans every stored cell of
the sheet per area.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:47:07 +00:00
Maciej Malinowski e9bcb8dfbb calc: one shared same_name for LET/LAMBDA names, with case folding
- gridcraft_formula::same_name lower-cases then upper-cases each
  character, so ß matches ẞ (to_uppercase alone maps ß to SS).
- Formula-bar input uses it too, so a binding such as ſi is not
  translated to IF while the evaluator binds it.
- Tests: ISOMITTED with a case variant, ß/ẞ, and the Spanish input case.
2026-10-19 21:46:10 +02:00
bfentyandClaude 9f13434972 fix(ui): label sort columns from the region start, seed unused level columns
- The region already starts at the header row when headers are on, so
  the dropdown label reads start.row for both cases (was showing the
  first data value, e.g. B: 30 instead of B: Revenue).
- + Add Level picks the first region column no level uses yet instead
  of columns.get(levels.len()), which re-seeded an already-used column.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:40:46 +00:00
Maciej Malinowski ac371be50d calc: LET and LAMBDA names ignore case in any script
Excel matches LET and LAMBDA names case-insensitively in every script,
so =LET(dług,100,DŁUG*2) is 200. The evaluator compared them ASCII-only
and returned #NAME?. The three lookups (name evaluation, ISOMITTED, a
LET variable shadowing a function name) now fold Unicode case.
2026-10-19 21:15:00 +02:00
bfentyandClaude e34b0aa345 fix(ui): persist the AutoSave preference across restarts
Closes #1901

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:07:18 +00:00
MarekSpodBiedry 37f7b43bca Fix pointer budget test: pass push_click a u32 click count (#525) 2026-10-19 21:06:55 +02:00
MarekSpodBiedry e8b3279d9f Merge origin/main into issue-525 2026-10-19 21:06:55 +02:00
MHandClaude Opus 5.5 ef339b9f2c Grid: the selection outline follows a mouse drag at once
The outline glides 0.12 s toward each new selection. While selecting with the mouse that makes
it trail the pointer, which reads as lag (the grid itself paints in under a millisecond per
frame). With a mouse button held it is now drawn on its cells at once, as in Excel; keyboard
moves still glide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: MH <q6rrzz@gmail.com>
2026-10-19 21:40:23 +03:00
Harrison BoothandClaude Sonnet 5.5 13e730d42a calc: calculate every formula read at the depth limit in one suspension
A formula at the nesting limit reading many uncalculated formulas (for
example SUM over a wide range) suspended the abandoned evaluations once per
cell read, restarting them each time. The other formulas read at the limit
are now collected and calculated together with the suspended one, before the
abandoned evaluations restart. Results, circular-reference and iteration
behaviour are unchanged. Adds a regression test (3000 suspensions before, 1
after).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:36:56 -05:00
Harrison BoothandClaude Sonnet 5.5 4445aa6ddf calc: document deref's full-shape rule for bounded references
The deref doc comment still said blocks are trimmed to the used range. Describe
the actual rule: blocks up to FULL_SIZE_CELLS, or up to FULL_SIZE_BOUNDED_CELLS
when they do not run to the sheet's edge, keep their full shape; the rest stop
at the used range. Docs only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:31:04 -05:00
Hamza 3979e48f89 Desktop theme: the watcher's link has a type off Linux, so the gate passes there
`cargo xtask ci` has not passed on macOS or Windows since cc21643:

  error: this let-binding has unit value
    --> crates/ui-egui/src/desktop_theme.rs:36:13
  36 |             let mut link = Link::default();

`Link` is the session-bus connection the watcher keeps between reads. Off Linux
there is no bus to keep, so it was aliased to `()`, which makes that binding a
unit binding: `clippy::let_unit_value` reports it and the gate's `-D warnings`
turns it into an error. CI runs Linux only, where `Link` is a real
`Option<Connection>`, so nothing catches it there.

Off Linux `Link` is now a unit struct rather than `()`. The binding has a type,
the lint does not apply, and nothing else changes: `read` still ignores it on
those platforms. An `allow` on the line would also work, but a named type says
what the thing is rather than silencing a lint that only fires on some
platforms.
2026-10-19 22:41:20 +05:00
Hamza e5aeea7a94 Sheet protection: obey the Sort and Use AutoFilter boxes
`SheetProtection` carries eleven permissions. `format_cells` is checked in
`apply_style`, and an edit to a locked cell is caught after the fact by
`check_protection`, but `sort` and `autofilter` had no reader anywhere outside
`sheet_read.rs`, `sheet_write.rs` and a round-trip test: they only ever
travelled through the file. So a protected sheet could still be sorted, and
Ctrl+Shift+L still put an AutoFilter on it.

`refuse_unless_allowed` sits beside `locked_cell_error`, with three call sites:
`do_sort` (which both Sort… and the A-to-Z buttons come through), `toggle_filter`
and `filter_by`.

Where the box decides is worth knowing. Sorting a range of locked cells was
already refused, by the locked-cell guard rather than the permission, because a
sort moves values between locked cells — which is Excel's rule too ("users
cannot sort ranges that contain locked cells on a protected worksheet,
regardless of this setting"). So the Sort box decides for a range that has been
unlocked, while an AutoFilter changes no cell's contents and the box is the only
thing that ever decided it. The test asserts both, and that an unprotected sheet
still sorts and filters.

Not fixed here: Delete Rows and Delete Columns are refused even when their boxes
are ticked, because deleting removes locked cells and trips the same post-hoc
guard, while Insert Rows and Insert Columns work. Letting the guard know which
operation ran is a change to code every command passes through and wants its own
PR.
2026-10-19 22:30:36 +05:00
Hamza aff9153dac Calc: CELL("filename") names the workbook's file (#1489)
It returned an empty string for every workbook, saved or not, so the classic
sheet-name formula `MID(CELL("filename",A1),FIND("]",…)+1,31)` gave #VALUE!:
with no `]` in the string there is nothing for FIND to find. Excel returns
`<folder>\[<file>]<sheet>`, and empty only before the first save.

The model had no file to report. `source_dir` could not serve: it names the
folder relative links to other workbooks are relative to, and must keep naming
it after a Save As into another folder, because the links held in memory are
still written for the old one — only the saved copy is rebased. `source_path`
is therefore its own field, with that distinction written down.

It is set when a workbook is opened, from the path made absolute so the folder
is the one the user sees, and when a full-fidelity save makes a file the
document's own — before the `saved` snapshot, or the workbook would read as
dirty the moment it was saved, and only when it changes, so an ordinary Save
does not clone the workbook for nothing. A workbook that arrived as bytes knows
its name but never a folder and reports `[name]Sheet`, which still splits on the
`]`; an import reports the file it came from, as Excel does. The sheet is the one
the reference is on, not the active one.

CELL is volatile and already in the volatile sets, so a cell holding it answers
from the next recalculation; after a Save As that means F9 rather than the save
itself, which the engine test asserts rather than papers over. Making a save
recalculate is a larger decision than this issue.

`check` still skips CELL("filename") as volatile, as docs/cli.md says: the
cached value in a file is the folder of the machine that saved it.
2026-10-19 22:29:37 +05:00
Harrison BoothandClaude Sonnet 5.5 43655df887 Merge main into chore/contributors-harrison-booth
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 12:25:06 -05:00
bfentyandClaude da97bde61c fix(ui): seed Sort dialog column from the active cell
The Sort dialog's 'Sort by' box was a free-text field hard-coded to
'A', so sorting a region in column B silently sorted column A unless
the user noticed and retyped the letter. It now seeds from the column
the cursor is in and renders a dropdown of the current region's
columns, labelled with their header text. '+ Add Level' seeds the
next region column instead of a hard-coded B.

Closes #1906

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 17:21:50 +00:00
Mouhand-Kaddo 6760e006ec M12.AR15: never write a font family over 14 (review) 2026-10-19 21:18:06 +04:00
Mouhand-Kaddo 24b0b5d167 M12.AR15: keep cell fonts' charset and family on save 2026-10-19 21:18:06 +04:00
Mouhand-Kaddo 4faca49836 M12.AR13: read Windows-1256 Arabic CSV and text files as Arabic 2026-10-19 21:18:05 +04:00
Camilo Agudelo 6481c17150 In-cell editor: test that an unwrapped multiline edit shows every line (#1970)
desired_rows(1) only sets the field's minimum height; it grows with its
text. Pins that an Alt+Enter edit in a cell without Wrap Text shows all of
its lines and reserves no more. Review feedback on #1974.
2026-10-19 12:02:48 -05:00
quantmind-br f84557b4c6 Merge remote-tracking branch 'upstream/main' into function-help
# Conflicts:
#	docs/localization-parity.md
2026-10-19 13:58:48 -03:00
bfentyandClaude d19627fc26 fix(engine): walk stored cells in transitive precedent selection
Bound the all-level walk to the sheet sparse cell store instead of
iterating every position of each precedent area: formulas late in a
wide area are no longer skipped, blank positions are never selected,
and only formula cells extend the frontier. Adds a regression test
(=SUM(A1:Z1) with a formula at Z1) and silences two clippy lints in
the same function.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 16:54:45 +00:00
Harrison BoothandClaude Sonnet 5.5 f8cec7dd6d Merge main into fix/deep-recursion-crash
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:54:10 -05:00
Harrison BoothandClaude Sonnet 5.5 5e0b2dd45c Merge main into fix/sumproduct-mismatches
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:49:08 -05:00
Camilo Agudelo 3df5e05f0f In-cell editor: measure wrapped text at the field's own wrap width (#1970)
With Frame::NONE the text field takes no text margin off its width, so it
wraps at the editor's width less the 2 px inset a side. Measuring 8 px
narrower counted rows the field doesn't have and made the editor reach
over the cells below. A test pins the field's wrap width. Review feedback
on #1974.
2026-10-19 11:43:44 -05:00
Brandon Thomas 49fe41afbf Merge pull request #1892 from vedran-dobos:options-prefix-translations
# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	docs/localization-parity.md
2026-10-19 12:33:01 -04:00
Brandon Thomas 4401b3e5c5 Merge pull request #1877 from SocraticsAI:host-chrome 2026-10-19 12:32:51 -04:00
Brandon Thomas aa4125952e Merge pull request #1910 from Silvertree2010:fix/1907-bug-touchscreen-devices-cannot-p 2026-10-19 12:32:51 -04:00
Brandon Thomas 8492416c74 Merge pull request #1916 from Silvertree2010:fix/1915-desktop-theme-watcher-opens-a-ne 2026-10-19 12:32:51 -04:00
Brandon Thomas 47fc3d5d35 Merge pull request #1922 from Silvertree2010:fix/h552-rangeref-parse-accepts-signed-ro 2026-10-19 12:32:51 -04:00
Brandon Thomas 70018d4e60 Merge pull request #1925 from DrM7md:nux/insert-delete-whole-lines 2026-10-19 12:32:51 -04:00
Brandon Thomas b402359fa4 Merge pull request #1938 from Mouhand-Kaddo:fix/html-export-rtl
# Conflicts:
#	docs/file-format-parity.md
#	docs/localization-parity.md
2026-10-19 12:32:51 -04:00
NetterSilverandClaude Haiku 5.5 3a2232cfef Enter bare numbers in Percentage-formatted cells as percents
Fixes #1967

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:31:57 +02:00
Brandon ThomasandClaude Opus 5.5 92c0d1e936 Chrome: turning the File tab off closes a Backstage that is already open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:23:08 -04:00
Brandon ThomasandClaude Opus 5.5 27f4c9c7bc Touch pan: a pan that never saw its release doesn't swallow the next press
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:22:57 -04:00
Brandon ThomasandClaude Opus 5.5 f5267cb74e docs: one Last updated line per parity doc after merging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:22:25 -04:00
Brandon Thomas b77848ee21 Merge pull request #1882 from neilcawse:fix/pivot-grand-totals
# Conflicts:
#	docs/file-format-parity.md
2026-10-19 12:22:07 -04:00
Brandon Thomas f8e916bcd4 Merge pull request #1902 from Shnrqpdr:fix/1846-autofit-keytips
# Conflicts:
#	docs/ui-parity.md
2026-10-19 12:22:06 -04:00
Brandon Thomas ab6fd48856 Merge pull request #1867 from HyakoV3:fix/1808-csv-as-text
# Conflicts:
#	docs/file-format-parity.md
2026-10-19 12:22:05 -04:00
Brandon Thomas 06a94d42b2 Merge pull request #1935 from Mouhand-Kaddo:fix/shape-text-rtl
# Conflicts:
#	docs/file-format-parity.md
#	docs/localization-parity.md
2026-10-19 12:22:04 -04:00
Brandon Thomas 7b8aaf9ca7 Merge pull request #1939 from Mouhand-Kaddo:fix/xls-right-to-left-sheets
# Conflicts:
#	docs/file-format-parity.md
#	docs/localization-parity.md
2026-10-19 12:22:02 -04:00
Brandon Thomas 12ee27a177 Merge pull request #1943 from Mouhand-Kaddo:fix/table-name-length
# Conflicts:
#	docs/localization-parity.md
2026-10-19 12:22:01 -04:00
Brandon Thomas 8d562e9b56 Merge pull request #1879 from Silvertree2010:fix/h2804-custom-autofilter-is-less-than-l 2026-10-19 12:21:34 -04:00
Brandon Thomas 937cfd1e91 Merge pull request #1903 from justinjohn0306:fix/1855-backspace-undo 2026-10-19 12:21:29 -04:00
Brandon Thomas 46c706a491 Merge pull request #1845 from MarekSpodBiedry:issue-1830 2026-10-19 12:21:28 -04:00
Brandon ThomasandClaude Opus 5.5 33ac9e5f64 Drop the dbus-run-session test: it spawns a bus, sleeps 9 s and fails where dbus-daemon is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:21:13 -04:00
Brandon ThomasandClaude Opus 5.5 8968e83359 RangeRef::parse: whole columns take at most one $ too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:21:01 -04:00
Brandon ThomasandClaude Opus 5.5 f2473d1f33 Ctrl+Shift+= on several whole-row areas still opens the Insert dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:20:46 -04:00
Brandon Thomas a6757829eb Merge pull request #1814 from Higgs32584:fix-1447-pivot-bool-error-flags 2026-10-19 12:19:54 -04:00
Brandon Thomas 29f9959521 Merge pull request #1866 from HyakoV3:fix/1528-formula-edit-sheet 2026-10-19 12:19:53 -04:00
Brandon Thomas 8e8e2ba73c Merge pull request #1920 from vedran-dobos:range-fill-formats 2026-10-19 12:19:51 -04:00
Brandon Thomas 33a5415f0e Merge pull request #1941 from Silvertree2010:fix/h618-textbefore-textafter-with-negati 2026-10-19 12:19:50 -04:00
Brandon Thomas 859e654dce Merge pull request #1923 from DrM7md:nux/clippy-1-95 2026-10-19 12:19:25 -04:00
Brandon Thomas 93cb9828bd Merge pull request #1926 from DrM7md:nux/rtl-explicit-align 2026-10-19 12:19:24 -04:00
Brandon Thomas 851583365f Merge pull request #1934 from Mouhand-Kaddo:fix/arabic-name-length 2026-10-19 12:19:23 -04:00
Brandon Thomas 448c167e68 Merge pull request #1947 from Mouhand-Kaddo:fix/xls-dialog-sheets 2026-10-19 12:19:17 -04:00
Brandon Thomas 9767efe66e Merge pull request #1965 from HaSh3003:fix/1714-outline-zero-width 2026-10-19 12:19:16 -04:00
bfenty 752477a231 feat(ui): Ctrl+[ / Ctrl+] select precedent and dependent cells (#1865)
Adds formulas.selectPrecedents / formulas.selectDependents engine commands
and binds them to Ctrl+[ and Ctrl+] (Ctrl+Shift walks every level), matching
Excel's shortcut parity. Direct-level traces keep whole precedent ranges;
cross-sheet precedents jump to the owning sheet; an empty trace leaves the
selection untouched with an actionable message.
2026-10-19 16:13:44 +00:00
Hamza ea00ca6b1f Control channel: a request its sender gave up on is not run later
A request that waited more than 60 s was answered with a timeout and left in
the queue, so `control::poll` ran it on the next UI pass and dropped the reply.
The client was told its command had failed while the command was applied: an
agent that retried after the error ran it twice, and one that gave up left an
unexpected change in the workbook.

`ControlRequest` now carries who has it -- pending, taken by the app, or
abandoned by its sender -- as the report suggests. `poll` claims each request
and skips one it cannot claim; the connection thread abandons the request when
its reply times out, and says which happened:

  still queued     timed out waiting for the UI (is the window minimised?)
  already started  timed out waiting for the UI, which had already started the
                   request, so it may still finish; its outcome is unknown

The second case cannot be called a failure without guessing. This is the shape
DeckCraft settled on for the same bug (its #719), so the two apps now agree.

Fix: #1914
2026-10-19 20:57:25 +05:00
Lord-Taw 71acb0c50b Atualizar o i18n.rs
CoderabbitAI
2026-10-19 12:56:10 -03:00
Lord-Taw 5c4dbbbc4a Atualizar o i18n.rs 2026-10-19 12:54:24 -03:00
Lord-Taw 8f239e3b2f Update i18n.rs
coderabbit
2026-10-19 12:41:11 -03:00
Hamza d890a1a7da Calc: a dynamic array of one value still anchors a spill range (#1491)
`C1#` gave `#REF!` while the dynamic-array formula in C1 happened to return a
single value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so
`ROWS(C1#)` and `SUM(C1#)` failed and `COUNT(C1#)` gave 0. Every formula that
counts or sums a filtered list therefore broke the moment the list had exactly
one entry. In Excel the formula is the anchor of a 1 x 1 range and the three
give 1, the value, and 1.

A pass recorded the spill only for an array larger than one cell:

    if a.rows > 1 || a.cols > 1 {
        self.spills.insert(k, a.clone());
    }

so a 1 x 1 result left no range for `spill_range` to answer with, and `eval`
turned the missing range into `#REF!`. It is recorded unconditionally now.

The report suggested keying this on the cell carrying `cm`, which turned out not
to be expressible: nothing in the model says "dynamic array" positively -- the
reader tells one from a legacy CSE formula only by leaving `Formula::array`
unset. The arm itself is the better test, because a formula whose result is a
scalar never reaches it: `=1+1` does not become an anchor, which a test pins.
It also matches Excel 365, where `=A1:A1` is a 1 x 1 anchor of its own.
2026-10-19 20:38:30 +05:00
Hamza 92e1e96997 Docs: §6's calculation bugs have all landed
gaps.md §6 still listed four things as missing and function-parity.md still
marked 15-significant-digit comparison as missing, citing #57 — all five
reports are closed and their PRs merged. The work list reads as the place to
pick work from, so a closed entry sends someone after work that is done.

Checked by behaviour, not by issue state, where that was possible:

  =0.1+0.2=0.3                                -> TRUE
  =(0.1+0.2)-0.3                              -> 5.55E-17  (full precision kept,
                                                 as in Excel: the 15-digit rule
                                                 is for comparison)
  =MATCH(0.3,{0.30000000000000004},0)         -> 1
  =COUNTIF({0.30000000000000004},0.3)         -> 1
  a spill blocked and then unblocked           -> #SPILL! then the spill again,
                                                 with its dependent back to 6

and by their regression tests for the other two:
`deleting_a_3d_span_end_sheet_keeps_the_rest` (#140) and
`sheet_operations_keep_names_on_their_sheets` (#134).

§6 is left open rather than deleted: it says wrong numbers are the worst kind of
bug, and five closed reports are not the same as the category being clear —
that needs §15's oracle suite. The function counts are untouched; this is
formula-language semantics, not the inventory.
2026-10-19 20:37:17 +05:00
HaSh3003 5eae222fbc Outline: + shows zero-width hidden columns at the default width (fixes #1714)
Templates hide grouped columns as width="0" hidden="1". Show Detail and the level buttons only cleared hidden, so the columns stayed invisible; they now reset a zero size, as Unhide does.
2026-10-19 18:21:54 +03:00
Camilo Agudelo 02ceedee17 In-cell editor: a Wrap Text cell edits wrapped at the column width
The in-cell editor ignored Wrap Text: it widened to the text's one-line
width, up to the grid's right edge, so a long wrapped cell became one
unmanageable line while edited. With Wrap Text on it now keeps the
column's width, wraps, and grows down to show every line, as Excel's does.
The multiline field is one row tall at least, not four, so a short edit
doesn't reach over the rows below.
2026-10-19 10:12:17 -05:00
Lord-Taw 31abf075b7 Translate and standardizes UI error feedback by locale
### Summary
This change centralizes the translation of user-facing error messages in the UI layer while preserving the original error payloads for machine consumption and AI-agent integrations.

### What changed
- Added a centralized error-translation path in `i18n.rs`.
- The UI now translates only the final display text, instead of altering the underlying engine or protocol error payload.
- If a translated string is not available, the original message is kept as a fallback, avoiding broken or overly-simplified output.
- The translation logic is applied at the presentation layer, not at the source of the error.

### Updated call sites
- Error and alert rendering in `widgets.rs`
- Dialog error presentation in `dialogs.rs`
- Print and feedback failures in `printing.rs`
- Top-level UI error handling in `lib.rs`

### Design intent
- Keep the raw engine errors intact for scripts, control channels, and AI/agent integrations.
- Present a clearer, localized message only to end users.
- Avoid modifying dictionary files or adding new translation entries to the global catalog unnecessarily.
- Keep the behavior consistent with the project’s accessibility and internationalization goals.

### Validation
- The focused i18n test passed successfully.
- The relevant UI test suite passed successfully.
- The repository-required CI gate was run and completed without failures according to the latest verification output.
2026-10-19 11:33:07 -03:00
Mouhand-Kaddo 7cd0a386aa M12.AR17: skip .xls dialog sheets instead of reading them as worksheets 2026-10-19 17:29:40 +04:00
Mouhand-Kaddo eee872e429 M12.AR16: limit table names to 255 characters 2026-10-19 17:04:06 +04:00
NetterSilverandClaude Haiku 5.5 e9ffc71c6a Search from the end for negative TEXTBEFORE/TEXTAFTER instances
Found while testing crates/functions/src/text.rs:511-552

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 12:27:46 +00:00
zyzabruh 041bee2168 Accounting format: quote the symbol as format text, refuse control characters (review #1930)
The symbol was interpolated with Rust's Debug formatting, which escapes a
control character into literal `\n`-style text. It is now written into the
format's quoted literal directly, and a symbol holding a control character is
refused along with a quote or a backslash, so nothing is escaped behind the
user's back.

Test: `accounting_format_takes_the_symbol_it_is_given` covers `\n` and a tab.
`cargo xtask ci`: 8/8 steps, 0 failures.
2026-10-19 14:03:14 +02:00
Mouhand-Kaddo a10ead1c09 M12.AR11: mark right-to-left sheets and Arabic text in HTML export 2026-10-19 15:59:46 +04:00
Mouhand-Kaddo 59074fdc16 M12.AR9: open right-to-left sheets from .xls files right-to-left 2026-10-19 15:59:46 +04:00
NetterSilverandClaude Haiku 5.5 d312535c40 Keep date text with signed parts like +3/4/2026 as text
Found while testing crates/core/src/parse.rs:214

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:58:46 +02:00
Mouhand-Kaddo 90344445d7 M12.AR10: keep right-to-left paragraph direction in text boxes and shapes 2026-10-19 15:46:21 +04:00
Mouhand-Kaddo fb7b23dbad M12.AR8: count characters, not bytes, in name length limits 2026-10-19 15:33:05 +04:00
zyzabruh 7776cfcc5c Accounting format: the documented currency symbol is honoured
`home.accounting` documents `{range?, symbol?}` but ignored the symbol: the
command always applied the `$` format, so a script or MCP client asking for
lempiras (#1893) or any other currency silently got dollars. The symbol is now
part of the code it writes, and a symbol that would break the format string (a
quote or a backslash) is refused instead of being pasted in. The default is
unchanged, character for character.

Tests: `accounting_format_takes_the_symbol_it_is_given` (displayed text for `L`
and the default, and the two refused symbols) and
`the_default_symbol_reproduces_the_accounting_format` (the built code still
equals the named `Accounting` format). `cargo xtask ci`: 8/8 steps, 0 failures.
2026-10-19 13:16:13 +02:00
NetterSilverandClaude Haiku 5.5 620be76873 Reject signed and multi-$ whole-row ranges in RangeRef::parse
Found while testing crates/core/src/addr.rs:217

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:48:47 +00:00
Vedran DobosandClaude Opus 5.5 6ac8ad9216 fix(edit): Ctrl+Enter gives each cell the number format typing gives it
range.fill parsed the input once at the active cell, then put back the old
style of every target that already held a cell, so 5%, $5, 3/4/2026 or
=DATE(...) lost their automatic format there (0.05, 5, 46085), and empty
targets took the active cell's style. Each target now gets what
input_to_cell gives for it (parsed once per distinct style), as typing does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:18:34 +02:00
MH 28cdf1d187 Fix two clippy lints new in Rust 1.95
CI installs the stable toolchain, and Rust 1.95's clippy fails
'cargo clippy --workspace --all-targets -- -D warnings' on main:

- xls/mod.rs: collapsible_match. The XF record's size check becomes a
  match guard; a full table still ignores the record (the catch-all arm).
- passthrough_tests.rs: nonminimal_bool. !is_some_and(x) is written as
  is_none_or(!x).

No change in behaviour.

Signed-off-by: MH <q6rrzz@gmail.com>
2026-10-19 13:08:41 +03:00
MH 62e52004f8 Keyboard: Ctrl+Shift+= and Ctrl+- act on whole rows and columns at once
With whole rows or whole columns selected, Excel inserts or deletes them
directly; it asks which way to shift only for other selections. Both
shortcuts always opened the Insert or Delete dialog, an extra step for
the most common case.

Signed-off-by: MH <q6rrzz@gmail.com>
2026-10-19 13:01:51 +03:00
MH 5bafacff69 Grid: row and column heading labels grow with the zoom
At high zoom the heading labels stopped at 16 points (the outline levels
at 14) while the cells kept growing, so at 300-400% the letters and
numbers looked tiny in their tall headings. Excel scales them with the
zoom. The labels now scale without a cap and keep their readable floor
at low zoom (heading_font_size).

Signed-off-by: MH <q6rrzz@gmail.com>
2026-10-19 12:59:24 +03:00
MH 86641d965b Grid: explicit Left/Right alignment keeps its side on a right-to-left sheet
Cells are painted left to right and a right-to-left sheet is mirrored
afterwards, so an explicit Align Right ended up on the left (and Align
Left on the right). Excel keeps an explicit alignment on the side it
names in either sheet direction; General alignment follows the sheet.

The alignment is now swapped before painting (painted_halign), for
straight and rotated text alike.

Signed-off-by: MH <q6rrzz@gmail.com>
2026-10-19 12:52:52 +03:00
NetterSilverandClaude Haiku 5.5 cc21643b83 Reuse one D-Bus session connection in the desktop theme watcher
Fixes #1915

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 09:44:48 +00:00
Vedran DobosandClaude Opus 5.5 adcb460a63 i18n: the test also checks Page Layout's Sheet Options labels (#1891)
From CodeRabbit's review of #1892: the fix also strips the prefix from
"Sheet Options|View" (the Gridlines and Headings checkboxes), but the test
only rendered File › Options. It now opens Page Layout in French and
Serbian too and checks that the checkboxes read "Affichage" / "Prikaz",
not "Options de la feuille|Affichage" / "Opcije lista|Prikaz". It fails
with the old catalog values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:54:50 +02:00
NetterSilverandClaude Haiku 5.5 9a79dbef47 Print CJK text with a CID font instead of question marks
Fixes #1908

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:26:12 +02:00
NetterSilverandClaude Haiku 5.5 0e8e7e34e4 Pan the grid on a one-finger touch drag instead of selecting
Fixes #1907

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:17:42 +02:00
NetterSilverandClaude Haiku 5.5 5f748d5594 Keep the in-cell editor at the cell's height for one line
Fixes #1755

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:49:44 +00:00
justinjohn0306andClaude Opus 5.5 dcd4d1d13f #1855: redo selects the redone cell
Undo stored the selection at the moment of undoing (wherever the user had
clicked) in the redo entry, so redo jumped back there instead of to the
cells it changed. The redo entry now keeps the undone step's selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:16:13 +05:30
justinjohn0306andClaude Opus 5.5 4db6876c07 #1855: Backspace then Enter is one undo step
Backspace clears the cell (edit.clearContents) and opens an empty editor.
Committing that editor ran cell.set "" on the already-empty cell, which
recorded a second, empty undo step, so Ctrl+Z appeared to do nothing and
the next Ctrl+Z undid an unrelated earlier edit. Committing empty text
into an empty cell now runs no command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:59:49 +05:30
Shnrqpdr 90e89d1b9f Contributors: add Shnrqpdr to people.toml 2026-10-19 00:14:35 -03:00
Shnrqpdr 4ed93c3b2c Keytips: letters typed with Alt held continue the sequence (fixes #1846)
Alt+H, O, A (AutoFit Row Height) and Alt+H, O, I (AutoFit Column Width)
ran nothing when Alt was still held for H, as Excel allows: any key with
Alt down cancelled keytips. KeyTips now tracks the left Alt key, and a
letter typed with it held that continues a known sequence is handled as
a keytip letter. Other Alt combinations still cancel; AltGr (right Alt)
and macOS Option are unchanged (#1864).
2026-10-19 00:14:35 -03:00
Vedran DobosandClaude Opus 5.5 f1015e1e6b i18n: French and Serbian Options headings without the context prefix (#1891)
fr.rs and sr.rs translated three `context|label` keys with the context
kept ("Options|Général", "Opcije|Opšte", "Options de la feuille|Affichage"
...). `Language::tr` shows a found translation as it is, so File › Options
section headings and the Sheet Options "View" checkboxes showed the prefix.
The values now hold only the label.

Tests: `translations_never_show_the_context_prefix` (no catalog shows a `|`
for any key) and `translated_options_headings_show_no_context_prefix`
(File › Options in French and Serbian); both fail on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:05:57 +02:00
Dylan M. Taylor 66ce6be474 docs: add SonarCloud and CodeRabbit badges to README (#1860)
* docs: add SonarCloud and CodeRabbit badges to README

* docs: put SonarCloud and CodeRabbit badges on their own line
2026-10-18 16:14:33 -06:00
Francisco Herrera 5f2fb705b8 Docs: correct stale chart round-trip notes 2026-10-18 16:42:19 -05:00
Neil Cawse 383bb7ba59 fix(xlsx): read and write PivotTable grand totals in Excel's direction
rowGrandTotals is the Grand Total row under the report and colGrandTotals the
Grand Total column at its right. Both were mapped the other way round; a
GridCraft round trip hid it, but an Excel file with only one grand total opened
with the other one.
2026-10-18 19:02:18 +00:00
Dylan M. Taylor 6e984bb84d ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:36 -06:00
NetterSilverandClaude Haiku 5.5 4f3853d4a9 Hide blank and text rows for numeric ordering custom filters
Found while testing crates/engine/src/cmd/data.rs:550 (custom_ok, used from apply_filter at :623)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:25:07 +02:00
Raleigh DexelandClaude Opus 5.5 9b8fde71ee feat(ui): let an embedding host turn off the title bar and File tab
SheetApp gains a non-persisted `chrome` field. Both parts stay on by
default; a host that owns its window and files can hide the title bar,
and the File tab together with the Backstage view it opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:10:28 -07:00
Dylan M. Taylor d4f256d6ee docs: fix number format capitalization, restore generic familiar items, and clarify parity/renderer notes 2026-10-18 12:49:40 -04:00
Eduardo GarciaandClaude Sonnet 5.5 95f8206bb9 #1808: file.open can keep every CSV/TSV field as text
file.open takes {csvAsText: true}: a CSV, TSV or TXT file opens with every field as the text
it holds, with no number, date or boolean conversion, so identifiers such as GTINs, SKUs and
ZIP codes keep their digits and leading zeros and save back unchanged. The default is
unchanged. The CsvOptions.parse_values switch already existed in the reader.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:49:32 -03:00
Eduardo GarciaandClaude Sonnet 5.5 beade8b734 #1528: a formula's references are outlined only on the sheet they belong to
While pointing at another sheet, a reference without a sheet name (A1) was outlined on the
sheet being shown instead of the sheet the edit started on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:32:47 -03:00
Dylan M. Taylor dd5c7da6fc docs: use generic pivot table terminology instead of PivotTable 2026-10-18 10:44:48 -04:00
MarekSpodBiedry 6082355c22 Translate the cell and header context menus into German (#1830) 2026-10-18 13:51:22 +02:00
Cyan ede2b435d5 Fxied CodeRabbit flags
Fixed: On macOS, Cmd-click sets mods.command, not mods.ctrl. Both calls therefore pass false for add, so point_header replaces the reference instead of adding a column or row union. Pass mods.command, as the adjacent selection path does. egui defines command as Cmd on macOS and Ctrl elsewhere. (docs.rs)
&
Fixed:  Point mode remains active when the user changes sheets. The extension closure combines whole-column or whole-row references by coordinates only and discards the previous reference’s sheet. A prior Sheet2!C:C reference can therefore be combined with column E on Sheet3, then emitted as Sheet3!C:E.

Compare the previous reference’s effective sheet with the active sheet before extending it. If they differ, use only the clicked range.
2026-10-18 17:23:57 +08:00
Cyan ef0b243cd3 Allow multi-column/row selection inside formulas with shift 2026-10-18 17:11:37 +08:00
Cyan e60a74e9cf Clicking a row/column while in a formula now highlights that row/column 2026-10-18 17:05:55 +08:00
Dylan M. Taylor 53e5227085 docs: drop references to competing commercial software 2026-10-18 01:18:42 -04:00
Mex c30fa3f1cf fix(functions): avoid overflow in median midpoints (#1579) (#1585) 2026-10-17 21:13:03 -06:00
Higgs32584andClaude Opus 5.5 c5a53a90ac #1447: booleans and errors count as strings in pivot cache flags
Excel counts TRUE/FALSE and error values as strings in a cache field's
<sharedItems>. GridCraft wrote containsString="0" (and counted booleans and
errors as separate kinds for containsMixedTypes) for such columns, which made
the file unopenable in Excel, even when the column wasn't used in the pivot.

Add Stats::strings() (text, booleans, errors) and use it for
containsSemiMixedTypes, containsString and containsMixedTypes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:41:43 -04:00
Dante BertuzziandClaude Opus 5.5 19f4387cca Conditional formatting: show a rule's number format (fixes #1163) (#1769)
The grid's look() and print's cell_look() layered a rule's fill, font and
borders onto the cell style but not its num_fmt, so a cell under a rule with a
number format kept its own. Take the rule's code when it isn't General (in a
differential style General means "not set", as layer_under treats it).

Print also re-formatted every non-General cell from its own style through
display::cell_text; format with the look's code instead, through a new
display::value_text_as that value_text now calls (CSV/Text output unchanged).
Show Formulas keeps cell_text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:37:09 -06:00
Dante BertuzziandClaude Opus 5.5 83189c0ccd Fonts: probe ChromeOS's /usr/share/fonts/chromeos for script fallbacks (fixes #1762) (#1768)
Crostini's cros-host-fonts mounts the host's fonts under
/usr/share/fonts/chromeos (noto/, notocjk/, crosextra/), which none of the
fixed Linux path lists reached: Thai, Hebrew, CJK and Symbols 2 fell back to
egui's Latin-only faces and drew tofu with the fonts installed, and the UI and
cell roles missed Noto Sans and Carlito. Add those folders to each list, with a
structural test that every list probes them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:35:20 -06:00
NetterSilverandClaude Haiku 5.5 217a8907ff Reject unknown value-field func in insert.pivotTable (#1757)
Found while testing crates/engine/src/cmd/pivot.rs:678 (func_param at :248)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:34:28 -06:00
Brandon ThomasandClaude Opus 5.5 823c56582d Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:03:36 -04:00
skylar taylor-barrick 8ffff19dda Merge pull request #1782 from skylartaylor/control-input
Close the control port on the first line that isn't a request
2026-10-17 12:14:09 -06:00
thanhbv102-arch 0fe4cd840c i18n: add Vietnamese interface translations
Adds Language::Vi (vi), following the per-language module pattern (crates/ui-egui/src/i18n/<lang>.rs). All 398 strings in the master table are covered, checked by the completeness test; vi-VN locale tags resolve to Vietnamese.
2026-10-17 20:19:47 +07:00
War2Darius c04c85579a Add Ukrainian localization (uk.rs) 2026-10-17 15:59:13 +03:00
Keooudone NanhthavilayandClaude Opus 5.5 fac8f858cb Fonts: render Lao text with an installed Lao face instead of tofu
egui's bundled fonts have no Lao, and none of the script fallback faces
(CJK, Thai, Hebrew, symbols) carry it on macOS, so Lao text in a cell,
the formula bar or the interface drew as boxes unless the cell's own font
had Lao glyphs. Add a `sys-lao` fallback: Leelawadee UI / DokChampa on
Windows, Lao Sangam MN / Lao MN on macOS, Noto Sans Lao / Phetsarath OT
on Linux. `fallback_covers_its_script` now checks a Lao sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:39:11 +07:00
parsherr 2321afd677 i18n: translate sheet tab menu and AutoSave toggle (fixes #1537)
The sheet tab context menu buttons (Insert Sheet, Rename, Move or
Copy…, Duplicate, Tab Color, Hide, Unhide…, Protect/Unprotect Sheet,
Select All Sheets), the navigation tooltips (Previous/Next/All sheets,
New sheet), and the AutoSave toggle label and tooltip in the ribbon were
all hardcoded English — they were never routed through lang.tr().

Fix:
- tabs.rs: wrap every hardcoded UI string with app.ui.language.tr(…)
- ribbon.rs: wrap the AutoSave label and hover text with lang.tr(…)
- i18n/cs,de,es,fr,ko,pt,ru,sr,zh: add the 130 missing translation
  entries (each language only gets the entries it was missing)

Translations produced from first-principles per language, following the
terminology used for other sheet/workbook strings already in each file.

Closes #1537
2026-10-17 12:13:47 +03:00
MarekSpodBiedry 963219200c Ctrl+Alt+letter that types a character no longer triggers the Ctrl shortcut (#1530) 2026-10-17 07:47:12 +02:00
NetterSilverandClaude Haiku 5.5 3f8167719a Keep chart source range in step with inserted and deleted lines
Found while testing crates/engine/src/cmd/sheet.rs:216-238 (rewrite never updates Chart.source); used by crates/engine/src/cmd/insert.rs:849 (chart_switch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 05:15:25 +02:00
TheRenegadeDevandClaude Opus 5.5 39c9d7cd63 Analysis ToolPak: moving-average standard errors use a running sum (fixes #1043)
forecast_grid re-added every squared residual in each window, O(n×window).
Keep a running sum instead: add the entering term, subtract the leaving one,
and restart at a point without a forecast so windows touching it stay #N/A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:30:24 -05:00
TheRenegadeDev 71c1097d5a #1054: clean up web print iframe and blob resources 2026-10-16 21:20:34 -05:00
NetterSilverandClaude Haiku 5.5 a6aa53cbff Move PivotTables with their cells on row/column insert and delete
Found while testing crates/engine/src/cmd/sheet.rs:115 (shift_sheet_features; it never touches sh.pivots)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:04:00 +02:00
TheRenegadeDev 8ca66c5c90 Use the platform clock for UI timing on the web (#1026) 2026-10-16 20:42:20 -05:00
TheRenegadeDev edcc76d0a8 Fail CLI checks on incomplete or empty input discovery (#1051) 2026-10-16 20:39:34 -05:00
TheRenegadeDev 5627d2b6bf Prefill the Remove Duplicates range from the selection (#1388) 2026-10-16 20:35:01 -05:00
Thiago 9e5cdf9e43 Tables: add the missing Resize Table button (#935)
table.resize has existed in the engine since #589/#1706 (overlap checks, tests), and the
command catalog already promised it under Table Design > Properties, but no ribbon button ever
called it -- the only way to resize a table was scripting the command directly (control channel,
CLI, MCP). #935 (no drag handle) assumed this menu path worked; it doesn't.

Adds a "Resize Table" button next to the Table Name field (same Properties group the catalog
already declares), opening a small dialog pre-filled with the table's current range. Confirming
runs table.resize with the typed range, same validation as #589/#1706 (rejects overlap with
another table, matching Excel's "A table can't overlap another table.").

The drag-handle part of #935 is unaddressed; this only restores the menu/dialog path.

Verified manually: created two tables, used the new button to grow one onto the other (rejected),
then to an adjacent non-overlapping range (accepted, table intact).
2026-10-16 22:22:33 -03:00
tisca26andClaude Opus 5.5 2a9f40c65e Rows: a big font isn't counted twice when the sheet's default height already fits it
auto_row_height estimated a line of a bigger font by scaling the sheet's
default row height by size / 11. When the sheet's default already fits
that font, the font counted twice: Excel for Mac saves rows of 16pt text
with defaultRowHeight="21" (28px) and no ht, and opening such a file
fitted every row to 40.7px, 1.45 times Excel's height. Rows mixing Middle
and Bottom aligned cells then looked misaligned.

The estimate now scales an 11pt row's height (DEFAULT_ROW_HEIGHT), so
those rows open at 29px, and rows on a 15pt-default sheet still grow to
their font (#833).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 16:38:11 -06:00
NetterSilverandClaude Haiku 5.5 36e45a5c58 Reject table.resize when the new range overlaps another table
Found while testing crates/engine/src/cmd/extra.rs:818-855 (table_resize, overlap check at ~832)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 23:35:20 +02:00
tydude001andClaude Opus 5.5 5539722557 Fonts: find the cell, serif, mono, Thai and Hebrew faces in Fedora's package folders (#1694)
Fedora keeps each font family in its own package folder, and the cell, serif, mono, Thai and
Hebrew candidates only listed the Debian/Ubuntu/Arch folders, so no cell face was found there and
bold and italic cells drew as regular text in the built-in font. Add Fedora's folders after the
existing Linux entries for each font, as #54 did for the UI fonts, and let mono fall back to
Liberation Mono and then Fedora's default Noto Sans Mono, since DejaVu Sans Mono is optional there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:30:07 -05:00
aviiciii 94af08ad20 XLSX: new workbooks get <fileVersion appName="gridcraft"/> (fixes #1699)
A workbook GridCraft created had no <fileVersion> in workbook.xml; it now
names GridCraft, and keeps it on later saves.

A workbook read from an Excel file still keeps its <fileVersion> as it was,
appName="xl" included. Observed in Excel 16.114: with the calcFeatures
extension present, Excel uses the saved cell values only when appName is
"xl". appName="gridcraft", "Calc" or no appName makes it recalculate
everything on open, which brings back #799 (lookups into closed workbooks
open as #CALC!). The application that last saved the file is recorded in
docProps/app.xml <Application>GridCraft</Application>, which GridCraft
already writes on every save and which Excel's recalculation ignores.
2026-10-17 01:26:43 +05:30
Harrison BoothandClaude Sonnet 5.5 70fc5e1c78 Merge main into fix/workbook-metadata-part
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:13:22 -05:00
Harrison BoothandClaude Sonnet 5.5 a28799d02b Merge main into fix/sumproduct-mismatches
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:12:11 -05:00
webdevtodayjason ea3ef8faf7 Charts: honor box and whisker quartile methods
Keep exclusive and inclusive quartiles per series when drawing and saving XLSX, and expose the setting through chart.set.

Fixes #1453
2026-10-16 12:37:27 -05:00
Nick GillandClaude Opus 5.5 8e7438b434 Charts: write names in chart references qualified, as Excel needs (#1686)
Checked black-box in Excel for Windows (Microsoft 365) with synthetic
workbooks: a bare name in <c:f> draws the whole chart blank, without a
repair prompt, for workbook- and sheet-scoped names alike, while
`[0]!Name` and `Sheet1!Name` plot.

- Write: a sheet-scoped name is written `Sheet!Name` (quoted when
  needed) instead of bare; an already qualified name keeps its sheet;
  an undefined name gets `[0]!`. A reference to an unknown sheet is
  kept as written. A qualified series name is written as a strRef.
- Resolve: a series name `Sheet1!Name` is looked up on that sheet, as
  the evaluator does, instead of being shown as literal text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:32:54 -04:00
Nick GillandClaude Opus 5.5 e8f049943f Charts: series that refer to workbook names ([0]!Name) plot their data (#1686)
Excel writes a workbook-scoped defined name in a chart reference as
`[0]!Name`. The reader kept that text, the lexer took `[0]` for a
structured reference, and the series evaluated to #NAME?: a chart over
dynamic (OFFSET) names opened blank with one #NAME? category.

- Read: `[0]!Name` becomes `Name` in c:f (cat, val, xVal, yVal,
  bubbleSize, tx), chartex cx:f and _xlchart names. External books
  ([1]Sheet1!...), quoted sheet names and strings are untouched.
- Write: a bare workbook-scoped name is written back as `[0]!Name`; a
  sheet-scoped name stays bare. A series name that is a defined name is
  written as a strRef instead of literal text.
- Resolve: a series name that is a defined name is evaluated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:54:04 -04:00
aiongg 27932a2ae4 Close control connections on the first line that is not a request
A line that is not a JSON object with a string method, including an HTTP
request line, bytes that are not UTF-8 or a line over 64 MiB, gets one
error reply and the connection closes before anything after it is read.
Request lines are read with a bound instead of buffering them whole.
2026-10-16 16:26:31 +00:00
Harrison BoothandClaude Sonnet 5.5 6adb26c719 contributors: add Harrison-Booth
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:44:57 -05:00
NetterSilverandClaude Haiku 5.5 84ce99f9c7 Keep overflow text visible when its source cell scrolls out of view
Fixes #1613

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:08:08 +02:00
ozero ec930b2fc8 fix: theme PivotTable field group buttons 2026-10-17 00:00:57 +09:00
Marco Arnulfo 042ac7060f macOS: bold interface text draws Cyrillic е open, not like ө (fixes #1184) 2026-10-16 16:50:12 +02:00
midasmatterhorn 4ca967e114 Auto-fit: skip merged cells when computing row heights 2026-10-16 16:30:18 +03:00
Marco Arnulfo 14f2ea9f57 Cell editor: the caret contrasts with the cell's fill (fixes #1408) 2026-10-16 14:53:20 +02:00
Marco Arnulfo 295f7dde33 Fill handle: right-drag opens the fill menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…) 2026-10-16 14:20:48 +02:00
midasmatterhorn 86b124fca6 Charts: build multi-area series from every selected area 2026-10-16 15:17:19 +03:00
harrisonoconnorhover 1131de97ff fix(ets): avoid repeated interpolation scans (#1042) 2026-10-16 08:12:47 -04:00
harrisonoconnorhover 4548c6f7fc fix(xlsx): bound repeated column expansion (#1022) 2026-10-16 07:44:44 -04:00
Bryan MarkandClaude Opus 5.5 3684349d9c Memory: floating pictures' textures are bounded, sized to what is drawn, and dropped with their workbook
Floating pictures were decoded at full size, with no limits, into one
process-wide map of textures that never let go, not even when their
workbook closed. Its key was the picture's id and byte length, and ids
are per workbook, so a second open workbook could show the first one's
picture.

The new floating_pictures module, built like cell_pictures, keeps a
cache per egui context keyed by workbook, picture id and size:
- bounded at 128 MB, least recently drawn first, never evicting a
  picture drawn in the last frame, so none turns into a placeholder;
- a workbook's textures go when it closes (checked every frame);
- a picture's header is read first (16,384 px a side, 64 MP at most),
  it is decoded with limits, and to the next size step of what is
  drawn (a power of two or three quarters of one, up to 4,096 px or the
  GPU's limit); it is decoded again only when drawn larger;
- a picture that can't be decoded is remembered, not retried each frame.

Measured on macOS 27 (Apple M3 Max): 12 pictures of 2600 x 2600 opened
from an .xlsx and scrolled through, textures held by egui 258 -> 40 MB,
settled footprint 738 -> 509 MB; after closing the workbook, textures
258 -> 0 MB, footprint 504 -> 236 MB. 24 pictures: textures 490 -> 76 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:26:53 +09:00
Bryan MarkandClaude Opus 5.5 a09d3f6934 Memory: undo steps share picture and macro bytes instead of copying them
Every edit copies the workbook for its undo step (copy-on-write: sheets
and 64-row bands of cells are shared). A floating picture's bytes
(Image.data) and the VBA project (Workbook.vba_project) were plain
Vec<u8>, so each edit on a sheet with a picture, and every edit in a
workbook with macros, kept another full copy of them, up to the 100
steps of the history. They are Arc<Vec<u8>> now, as cell pictures
already are, with base64 helpers for serde (b64::shared, b64::opt). JSON
and XLSX are unchanged.

Measured on macOS 27 (Apple M3 Max), gridcraft-cli, kernel peak
footprint: a 19 MB picture and 50 cell edits on its sheet, 992 -> 22 MB;
10 edits, 215 -> 21 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:26:53 +09:00
Bryan MarkandClaude Opus 5.5 c5baa829e3 Memory: interface fonts are read once and shared with egui, not copied into it
egui copies an owned font's bytes into its text engine (epaint's
blob_from_font_data clones the Cow) and keeps the original FontData too,
so every interface and script fallback font was in memory twice: about
100 MB on a Mac, where the fallbacks alone (Hiragino Sans GB, Hiragino
Sans, Apple SD Gothic Neo, Thonburi...) are 89 MB of files. try_load now
keeps each file it reads for the rest of the run and hands egui borrowed
'static bytes, which it shares. These faces are loaded by every font
rebuild anyway, so keeping them costs nothing, and rebuilds no longer
read them from disk again. A missing or broken file is looked for again
next time. Which faces load, and their order, are unchanged; workbook
fonts (bind_faces) load as before.

Measured on macOS 27 (Apple M3 Max), offscreen snapshot held after its
picture, settled footprint: empty workbook 258 -> 158 MB, the sample
workbook 261 -> 160 MB, a cell in five scripts 258 -> 158 MB; the
rendered pictures are byte-for-byte the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:26:53 +09:00
Marco Arnulfo efc8171609 Keyboard: Ctrl+6 hides and shows objects, saved as workbookPr showObjects 2026-10-16 07:03:58 +02:00
derechopiano 214a67d53c feat(#1622): add Advanced Filter ribbon dialog 2026-10-16 05:57:03 +01:00
derechopiano 3379fb63a5 fix(#1031): constrain Advanced Filter copy output 2026-10-16 05:33:52 +01:00
Marco Arnulfo 0033dfcdeb Keyboard: Ctrl+Shift+A after a function name inserts its argument names 2026-10-16 06:24:36 +02:00
NetterSilverandClaude Haiku 5.5 ad4422f5f6 Reject unsupported flip param in arrange.rotate
Found while testing crates/engine/src/cmd/extra.rs:664 (command doc at extra.rs:114)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 05:59:22 +02:00
Dylan M. Taylor 125c8b00e8 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:58:06 -04:00
skylar taylor-barrick f1894c6da6 Merge pull request #1590 from dylanmtaylor/ci/shared-freebsd-workflow
ci: delegate FreeBSD workflow, add nix check, and remove flatpak-pages
2026-10-15 21:26:45 -06:00
Marco Arnulfo ac8bbd84eb Grid: a cell with line breaks and Wrap Text off draws the whole string on one line (fixes #1604) 2026-10-16 05:14:19 +02:00
Marco Arnulfo e8c6fcb8b0 XLSX: keep custom column widths that round to the default width (fixes #1607) 2026-10-16 05:11:07 +02:00
Marco Arnulfo f3398ee218 Grid: a scrolled Table's header names and filter buttons in the column headings (#1423) 2026-10-16 04:27:49 +02:00
Marco Arnulfo 706127402a XLSX: keep header and footer pictures on save (part of #1257) 2026-10-16 02:48:25 +02:00
Dylan M. Taylor e91e7f7a68 ci: delegate FreeBSD workflow, add nix check, and remove flatpak-pages 2026-10-15 20:47:20 -04:00
skylar taylor-barrick bb685e3275 Merge pull request #1588 from marcoarnulfo/fix/filter-menu-enter-applies
AutoFilter: Enter in the filter menu applies the filter and closes it
2026-10-15 18:28:45 -06:00
Marco Arnulfo 81422c315d Contributors: add my name for the About window 2026-10-16 02:19:55 +02:00
Marco Arnulfo 79e8973c30 Web: keep preferences such as dark mode between visits (fixes #1562) 2026-10-16 01:35:02 +02:00
Marco Arnulfo fda8e7b703 AutoFilter: Enter in the filter menu applies the filter and closes it (fixes #1516) 2026-10-16 01:13:10 +02:00
NetterSilverandClaude Haiku 5.5 d50e32bf25 Docs: put the new sentence in the Fidelity / notes column
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:55 +00:00
NetterSilverandClaude Haiku 5.5 1ab9e60d88 Reject formula number literals too large for f64 as parse errors
Found while testing crates/formula/src/lexer.rs:258

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 22:23:34 +02:00
NetterSilver 9f6bd5848c Docs: update the progress docs for this change 2026-10-15 20:17:42 +00:00
NetterSilver 47185eef1f XLSX export: a picture shared by several sheets is one media part (#985)
The writer gave every drawing its own xl/media/imageN part even when the pictures
had the same bytes, so a workbook with one logo on 16 sheets grew 16 copies of
it on a plain open and save. Out::image_part now returns the existing part when
an identical picture was already written (same extension and bytes), and every
drawing's relationship points to it, as Excel stores a copied picture.
2026-10-15 20:17:27 +00:00
Nihilist NarwhalandClaude Opus 5.5 82ee02aac4 Formula parser: Excel's 8,192-character limit and postfix nesting (never crash) (#1330)
A hostile file could abort any process that opens it: a cell formula of a
million `%` (`1%%%…`) or a 200,000-term sum parsed into trees deep enough to
overflow the stack when printed, saved, evaluated or dropped, even in release
builds. Postfix operators (`%`, invoking a result) built their nesting in a
loop that the 256-level limit did not see.

- `parse` refuses formula text longer than Excel's 8,192 characters
  (`MAX_FORMULA_LEN`, counted in characters, the leading `=` excluded). This
  also bounds the depth of the left-deep trees long operator chains build.
- Each postfix `%` and each call of a result counts towards the nesting limit.
- A file's formula that is refused keeps its text and cached value, as other
  unparsable formulas do.

Regression tests: formula (small-stack thread: long `%`, invoke and operator
chains are errors; the limit counts characters, not bytes) and xlsx
(`hostile_formula_text_does_not_overflow_the_stack`: open, save and drop a
workbook with such formulas and a defined name on a 1 MiB stack). Both abort
with a stack overflow without the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:39:28 -06:00
Harrison O'Connor-Hoover 79e85c1b80 fix(edit): recalculate every cell changed by large clears (#1032) (#1128) 2026-10-15 13:38:13 -06:00
Marco Arnulfo 7a352cee09 Grid: an armed Format Painter paints whole rows and columns from their headers (refs #1211) 2026-10-15 21:22:40 +02:00
Marco Arnulfo c0d6f290e7 Name Manager: preview a name over several ranges through parse_name 2026-10-15 21:20:52 +02:00
NetterSilverandClaude Haiku 5.5 2bb222aea6 Sort and de-duplicate data.textToColumns fixed-width breaks
Found while testing crates/engine/src/cmd/data.rs:826

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:48:47 +02:00
ismaelezequiel c6be911b99 ui: truncate long workbook titles in the title bar 2026-10-15 12:43:48 -03:00
NetterSilverandClaude Haiku 5.5 883a07fb7b Keep nan/inf time and fraction look-alikes as text
Found while testing crates/core/src/parse.rs:~170 (parse_time, parts parsed with f64::parse)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:31:12 +02:00
MarekSpodBiedry 27dab49e3d UI: Delete dismisses the AutoComplete proposal, and deleting text never brings it back (#1521) 2026-10-15 17:16:35 +02:00
MarekSpodBiedry 98f4b9d855 Focus the Find what field when Find and Replace opens (#1518) 2026-10-15 17:16:20 +02:00
MarekSpodBiedry 0ec27a6101 Open the Insert Chart dialog from Recommended Charts instead of inserting a chart (#1399) 2026-10-15 17:15:12 +02:00
midasmatterhorn f8cbfb7cb6 engine: sort and remove duplicates move the rows' notes and hyperlinks
do_sort moved cells, formulas, cell pictures, row heights and floating
object anchors through the sort permutation, and remove_duplicates moved
cells and pictures through the compaction, but neither touched
Sheet::comments or Sheet::hyperlinks (keyed by CellRef): a note that
described one record stayed behind and annotated whatever landed on that
row — 'late payment' on a paid invoice — and a note on a row removed by
Remove Duplicates annotated blank cells. Sheet::comments/hyperlinks are
exactly the range-scoped side data that row and column inserts already
remap and edit.clear already clears, so the omission looks unintended.

Both maps now travel through the same permutation the cells use (the
sort collects before re-inserting, so a swap cannot resurrect an entry
from a destination that is also a source; Remove Duplicates compacts the
kept rows and drops the rest).

Regression test: sort_and_remove_duplicates_move_the_rows_notes_and_links
— on main the note and link stay on their old cells; with the fix they
travel with their record.
2026-10-15 17:40:18 +03:00
midasmatterhorn e6c0f92d59 engine: a cut takes the source's notes, hyperlinks and merged areas with it
A cut removed the source's cells and pictures and then re-created the
block's merged areas at the destination and copied its notes there — but
removed nothing from the source, so one merged block became two (an
empty one left behind) and a note ended up on both the old and the new
cell. Excel moves both and leaves the source clean.

The cut now clears the source range's comments, hyperlinks and merges
the same way edit.clear does. A second gap in the same path: paste never
transferred hyperlinks at all, so clearing the source would have lost
them — paste now carries hyperlinks (in What::All and a new
What::Hyperlinks, so edit.pasteSpecial {"what":"hyperlinks"} works
like "comments").

Regression test: a_cut_moves_the_sources_notes_hyperlinks_and_merges —
on main the old merge stays behind and the note is on both cells; with
the fix the merge, note and link all move and the source is clean.
2026-10-15 17:30:35 +03:00
midasmatterhorn 7deb382f3d xlsx: keep a table's filter criteria when the sheet AutoFilter coincides with it
The engine's live filter state is always sheet.autofilter — apply_filter,
filter_values, clear_filter, reapply and the slicer command all read and
write it, and a table's header dropdown installs one over the table's own
range — while Table::filter is only ever filled by the reader. Writing
suppressed the sheet <autoFilter> whenever any table merely intersected
it and wrote the table part from Table::filter, which was empty: a
filtered table saved with its rows still hidden and no criteria anywhere,
so on reopen there was no filter to clear and Excel showed an inactive
filter over mysteriously hidden rows. A slicer's selection was lost the
same way.

Fold the coinciding sheet filter's criteria into the table part (colId
offsets are relative to the range's first column, and both ranges start
at the same column, so they carry over verbatim); a filter that only
partially covers a table still has nowhere valid to go and keeps being
suppressed.

Regression test: a_table_filter_criteria_survive_a_round_trip — on main
the table part carries no criteria after a save; with the fold they round
trip, the hidden row stays, and the sheet keeps no duplicate autoFilter.
2026-10-15 17:22:31 +03:00
midasmatterhorn 18fecf8e8c engine: pageLayout.printTitles only touches the axes it is given
print_titles read both rows and cols as Options and assigned both
unconditionally, so {"rows":"2:2"} set title_cols = None and
{"cols":"A:A"} set title_rows = None: the repeated rows or columns
disappeared from every printed page with no message, and there was no
way to set or clear just one axis. The sibling pageLayout.pageSetup
already distinguishes "key absent" from "key present but empty"; read
both axes the same way — absent leaves the axis alone, empty/null clears
it, anything else that is not a range is an error rather than a silent
wipe.

Regression test: print_titles_keeps_the_axis_it_is_not_given — on main
the rows-only call clears the columns; with the fix each axis is touched
only when named, an explicit empty value still clears, and a bad range
errors.
2026-10-15 17:18:19 +03:00
midasmatterhorn 3f9b73b927 functions: NETWORKDAYS.INTL accepts the all-weekend string "1111111"
weekend_mask rejected the all-weekend string outright for both
NETWORKDAYS.INTL and WORKDAY.INTL, but Microsoft documents them as
differing: NETWORKDAYS.INTL "Using 1111111 will always return 0",
WORKDAY.INTL "1111111 is an invalid string". The crate already
implements that split — workday_core's per_week == 0 guard — which was
unreachable dead code while the extra clause stood. Drop the clause:
NETWORKDAYS.INTL counts zero workdays as documented and WORKDAY.INTL
errors through the guard it already has.

The existing test asserted the old behaviour and is updated.
Regression: working_days — on main the NETWORKDAYS.INTL assertion fails
(#VALUE! where Excel returns 0); with the clause dropped it passes and
WORKDAY.INTL is asserted to error.
2026-10-15 17:12:41 +03:00
NetterSilverandClaude Haiku 5.5 67be35564c Keep leading empty columns and rows when saving CSV/text
Found while testing crates/xlsx/src/csv.rs:204 (write_csv_limited, starts at sheet.used_range().start instead of A1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:01:47 +02:00
Eduardo GarciaandClaude Sonnet 5.5 ea01ad999b fix #1443: Ctrl+Q no longer closes the window outside macOS
egui's default quit shortcut closed the window without asking to save.
Clear it off macOS (Cmd+Q stays the app's Quit there) and add a regression test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:49:49 -03:00
quantmind-br 47268a42af UI: function help follows the interface language
The argument hint, autocomplete, Insert Function and the Formulas-tab menus
read per-language tables of function descriptions and argument names
(i18n/<lang>_functions.rs), keyed by the catalog's English text and
binary-searched. The hint is assembled from the formula syntax's function
name and separator plus the language's argument names, all of them or none,
so it never mixes two languages. Insert Function searches the translated
descriptions and shows readable category names. SPANISH_HELP moves to
es_functions.rs with its 10 functions.
2026-10-15 10:49:13 -03:00
justinjohn0306 b930d60bc3 #1505: keep scalar INDEX MATCH formulas scalar 2026-10-15 18:32:00 +05:30
NetterSilverandClaude Haiku 5.5 e1902111d8 Read securities function dates in the workbook's date system
Fixes #1457

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:29:44 +00:00
adrdsouza 7f9f73d6ba fix(numfmt): share legacy colors across grid and PDF rendering 2026-10-15 16:10:07 +05:30
derechopiano 9097170b08 XLSX: preserve carriage returns in saved text 2026-10-15 10:33:57 +01:00
Harrison BoothandClaude Opus 5.5 4567aed5fb fix(engine): AutoFill refuses targets that don't hold the source in a corner
edit.autoFill capped source and target at 5M cells separately, but fill()
writes one cell per source lane per step, and it never checked the target
held the source. Whole rows into whole columns (`2:3` into `A:A`) filled
every one of 16,384 columns down a million rows: ~17 billion writes, then
a 24-51 GB allocation abort that catch_unwind can't stop.

Excel (observed via COM on a synthetic workbook) refuses AutoFill when the
target doesn't contain the source or holds it away from every corner, and
accepts any corner-sharing target. edit.autoFill now applies that rule
(fill::holds_source_in_corner), and fill() bounds lanes x steps by
MAX_FILL_CELLS as a backstop. The 5M literals in auto_fill and fill_dir
use the shared constant.

Regression test: fill::tests::autofill_refuses_targets_that_do_not_extend_the_source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:39:54 -05:00
icsimpiantiandClaude Opus 5.5 e1e02f82f1 i18n(it): Foglio previsione on the ribbon button too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:21:40 +02:00
icsimpiantiandClaude Opus 5.5 a770845ebf i18n(it): align ten labels with Excel's Italian terminology
Larghezza predefinita, Cancella regole dall'intero foglio, Foglio previsione,
Giorno feriale, Singola/Doppia contabilità, Statistiche, Layout rapporto,
and the Space key name in the command search hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:21:15 +02:00
wstock 45644d3f43 Control channel: ui.dialog.confirm sends what OK sends (#1459)
OK runs a dialog's command with build_params(&d), which reshapes the
field values for several dialogs (the rule wrapper of cfQuick, the
delimiters list of Text to Columns, the columns of Remove Duplicates and
Subtotal, Data Validation's unused fields, Zoom presets, Series, Define
Name). ui.dialog.confirm passed the raw field values instead, so a
Highlight Cells rule confirmed over the control channel added nothing
and Text to Columns ignored the ticked delimiter.

Make build_params pub(crate) and use it from ui.dialog.confirm, say so
in the control protocol doc, and add tests driving the Greater Than and
Text to Columns dialogs over the control channel.
2026-10-15 07:06:31 +00:00
wstock 348d955a0a Conditional formatting: text rules never match an error value (#1162)
In Excel the "contains", "begins with" and "ends with" rules are
SEARCH/LEFT/RIGHT formulas, which fail on an error value, so an error
cell is never formatted by them and always matches "does not contain".
GridCraft matched on the error's display text instead, so #N/A
"contained" a and "began with" #.

Skip error values in the three positive rules and match them in
notContainsText, with a regression test over =NA(), =1/0 and text cells.
2026-10-15 06:57:38 +00:00
icsimpiantiandClaude Opus 5.5 2d4a537c6a i18n: add Italian (it) UI translation
Adds Language::It ("it", native name "Italiano") with a new per-language
module crates/ui-egui/src/i18n/it.rs, following the existing pattern
(translate(text) -> Option<&'static str>). The catalog has 971 entries:
every key the most complete catalog (pt) translates, plus the PivotTable
Fields pane strings (from es) and Options|Editing. Labels follow Excel's
Italian terminology (Foglio, Cartella di lavoro, Blocca riquadri, Unisci e
allinea al centro, Somma automatica...). Function names are not touched.

Also: it / it-IT / it_CH tags resolve to Italian (system-language default),
Language::ALL and the coverage/tag/age tests include Italian, and
docs/localization-parity.md lists the new language.

Not compiled locally (no Rust toolchain on the author's machine); relying on CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:52:36 +02:00
euisuhandOpenAI Codex 370b220834 fix(solver): commit solutions and reports atomically
Prepare report data before editing so refused reports cannot leave solution cells or the saved model changed. Commit accepted reports with the optional solution and model, retaining array/protection checks and row autofit after sheet insertion.

Closes #1050

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-15 15:23:19 +09:00
NetterSilverandClaude Haiku 5.5 cdc8177fae Let GROUPBY and PIVOTBY take a LAMBDA as their function
Fixes #903

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:09:18 +02:00
CouchRiv d59e1cf604 Merge remote-tracking branch 'up/main' into fix/sheet-param-honoured 2026-10-15 01:36:08 -04:00
CouchRiv bc524fa48e Merge remote-tracking branch 'up/main' into fix/conditional-format-style-colors 2026-10-15 01:36:05 -04:00
Camthalion77 4d68300bc2 Add Greek (el) localization 2026-10-15 08:31:40 +03:00
NetterSilverandClaude Haiku 5.5 22157433ce Fix fraction numerators saturating at u64::MAX for large values
Found while testing crates/numfmt/src/render.rs:470

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:48:41 +02:00
NetterSilverandClaude Haiku 5.5 1b4df8acc2 Refuse table names that clash with defined names or R1C1 references
Found while testing crates/engine/src/cmd/insert.rs:477

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:22:02 +02:00
NetterSilverandClaude Haiku 5.5 9b155c48f7 Stop BINOM.INV/CRITBINOM hanging when trials exceed 2^53
Found while testing crates/functions/src/stat.rs:1244

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:43:57 +00:00
Mario f065f9168a Clean up Command Palette code 2026-10-15 05:18:08 +02:00
skylar taylor-barrickandskylartaylor e6b6b34970 ci: add comfortable runtime limits to unbounded jobs (#1418)
Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-15 05:16:03 +02:00
derechopiano be20ca163d UI: keep PivotTable field-list scrollbar at pane edge 2026-10-15 04:13:02 +01:00
David ArdilaandClaude Opus 5.5 cd94a6d7b4 Recalculation: a spill anchor given a formula that reads its old spill area sees it empty
When a dynamic array's anchor gets a new formula, the formula is evaluated
while the old array is still laid out, so it can read the old spilled
values. The anchor was then never evaluated again: it is left out of its
own spill area's dependents, so as not to read its own array.

- `A1` was `=SEQUENCE(3)` and becomes `=SUM(A2:A3)+1`: it showed 6 (2 + 3
  + 1) with A2:A3 empty; recalculating from scratch gives 1.
- `A1` was `=SEQUENCE(3)` and becomes `=SEQUENCE(1,A2+1)`: it read A2 as 2
  and spilled over A1:C1 instead of showing 1.

The anchor is now evaluated again when its old array is gone or partly
freed. It is still left out when its new array covers the old one, where
it would read its own array.

Regression test in calc
(`an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty`:
the new formula stops spilling, spills elsewhere, or spills over part of
the old area); it fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:05:37 -05:00
David ArdilaandClaude Opus 5.5 ac7595975a Recalculation: formulas reading an array that loses a spill conflict see #SPILL!
When two dynamic arrays of one recalculation want the same empty cells,
both look free while the pass runs, so formulas evaluated in that pass
read both arrays. The one laid out second becomes #SPILL! at write-back,
but nothing evaluated its readers again, and they kept its values.

With B1 `=SEQUENCE(3)` and A2 `=SEQUENCE(1,3)` (both want B2), a full
recalculation (opening a file, Calculate Now) leaves A2 at #SPILL!, yet
`=A2*10` showed 10, `=SUM(A2#)` 6, and `=C2` and `=SUM(C2:C3)` 3, though C2
is empty. Entering the same formulas one at a time gave the right values.

An array blocked at write-back now sends the formulas that read its anchor
or the area it would have spilled into to the next round, where they read
#SPILL! and empty cells.

Regression test in calc
(`formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors`);
it fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:05:35 -05:00
Mario 4bfd0bc5a1 Improve Command Palette keyboard navigation and layout 2026-10-15 03:23:16 +02:00
Harrison BoothandClaude Opus 5.5 01eb237209 Docs: corpus count for dropped dynamic-array metadata
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:29:44 -05:00
Harrison BoothandClaude Opus 5.5 7ee11fe137 XLSX: keep dynamic-array cell metadata (cm) on save
A formula read with `cm` (Excel's XLDAPR dynamic-array record in
xl/metadata.xml) now carries `Formula::dynamic`, and the writer keeps the
`cm` and the metadata part for it even when the formula returns one value
and the export shape analysis would not need array evaluation. Before, such
formulas were saved as legacy formulas, so Excel recalculated some of them
with implicit intersection (SUMPRODUCT(IFERROR(range/range,0)) changed
value), and workbooks whose every dynamic formula was scalar lost
xl/metadata.xml altogether.

The mark survives moves, row and column insertion or deletion, sheet and
name renames and LAMBDA call-case fixes; retyping a formula drops it and
the writer decides from the formula's shape, as before. It is serialized
only when set, so saved documents without it are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:05:03 -05:00
Mario f9802ecc9d Improved font dropdown row sizing and scroll alignment 2026-10-15 01:15:15 +02:00
Harrison BoothandClaude Opus 5.5 ced7243234 calc: bounded references up to 1,048,576 cells keep their full shape (#1010)
A reference over 16,384 cells was cut to the sheet's used range when it was
read into an array. Next to a full-size operand the two no longer lined up:
SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2)) gave #VALUE!,
D3:AZ400*B3:B400 filled with #N/A, and ROWS, COUNTBLANK and --(r="") lost
the blanks past the data. Excel keeps the reference's size.

Bounded references now keep their full shape up to one column's worth of
cells (1,048,576). References that run to the edge of the sheet (A:A,
A2:A1048576, several whole rows) and larger blocks still stop at the used
range, so whole-column formulas stay fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:36:36 -05:00
midasmatterhorn 34a4ed3ead engine: rebuild cell-store bands and counts when importing a JSON workbook 2026-10-15 00:09:25 +03:00
vangel76andClaude Sonnet 5.5 0d18137114 Protection: enforce selectLockedCells/selectUnlockedCells and offer them in Protect Sheet (#1346)
selection.set refuses a cell the protection forbids selecting, selection.move steps on to the
next selectable cell, and review.protectSheet keeps the sheet's current selection rights unless
told otherwise. The dialog gains the two select options and insert/delete columns.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 23:00:50 +02:00
midasmatterhorn d66ed11253 xlsx: keep every property of the default cell format when importing a file 2026-10-14 23:58:57 +03:00
vangel76andClaude Sonnet 5.5 33abe3a9f7 UI: Protect Sheet entries become Unprotect Sheet on a protected sheet (#1346)
Home > Format, HOP and RPS always opened the Protect Sheet dialog, which re-protected
the sheet. open_dialog now turns protectSheet into unprotectSheet for a protected sheet,
and unprotects at once when there is no password.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 22:57:29 +02:00
vangel76andClaude Sonnet 5.5 833fe0da1f Edit: text that would parse as a number or formula keeps its quote prefix in the editor (#1362)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 22:56:36 +02:00
NetterSilverandClaude Haiku 5.5 ac8f6f6ce4 Keep Middle/Bottom vertical alignment for wrapped text taller than its row
Fixes #1347

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 22:56:35 +02:00
midasmatterhorn 2c23b61908 engine: subtotal honours header:false instead of dropping the first data row 2026-10-14 23:51:59 +03:00
DCM4711andClaude Opus 5.5 7b10212b5a Header menus: Format Cells… for the selected rows or columns
As in Excel, right-clicking a row or column heading offers Format
Cells…, between Clear Contents and Row Height… / Column Width….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:43:51 +02:00
midasmatterhorn 686c13c8b7 engine: keep case-insensitive replace from dropping text when folding shifts byte boundaries 2026-10-14 23:22:05 +03:00
Mario 50f8c0d42b Fix font dropdown resizing and keyboard navigation 2026-10-14 22:16:10 +02:00
midasmatterhorn 938762d796 engine: keep AutoFill text series from panicking or wrapping on i64 overflow 2026-10-14 22:56:07 +03:00
DCM4711andClaude Opus 5.5 d48bcaf043 Context menu: the right-click that opens it doesn't close it
A quick right-click delivers press and release in one frame. The menu
opens at the pointer, and its corner snaps to the nearest physical pixel
(252.46 -> 252.5 on a Retina display), which can leave the pointer just
outside it. The close check then saw that frame's press as a click
outside the menu and closed it right away, so the menu often flashed and
vanished on macOS.

The menu remembers the frame it opened in, and that frame's press no
longer counts as a click elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:55:20 +02:00
DCM4711andClaude Opus 5.5 a0ec1fb785 Selection: whole columns and rows don't widen to merged cells
Clicking column B's header on a sheet with A2:C2 merged selected A:C,
because selection.set grows every area until merged cells are whole.
Excel leaves header selections alone: column B stays B:B and row 3 stays
3:3. Cell ranges still take merged cells whole (B1:B3 -> A1:C3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:24:56 +02:00
Harrison BoothandClaude Opus 5.5 1241fbf260 calc: calculate long dependency chains with bounded stack
A formula that reads one later in row-major order (further down, or on a
later sheet) is calculated on demand, inside the formula that reads it.
Each level costs stack, so real models with chains of about 1,000 such
formulas overflowed the 1 MB main-thread stack on Windows and crashed on
open. Past 2,000 levels the chain gave #CIRC! instead.

On-demand evaluation now goes at most 64 formulas deep. A formula needed
deeper suspends the evaluations in progress: it is calculated from the
top, then the suspended ones again, reading the results kept so far.
Chains of any length calculate, with the same results. A suspended
formula read again is a circular reference, as a formula in progress is,
so loops are found at the same cell as before.

Regression tests build the chains in code: 20,000 formulas each reading
the row below, the same hopping between two sheets, a 5,000-formula loop,
and a loop closing into the middle of a deep chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:08:42 -05:00
Marco Arnulfo 62729e3a99 Keyboard: Alt+= no longer starts an edit with the = it types (fixes #1327) 2026-10-14 19:52:36 +02:00
MarekSpodBiedry ef894c20e5 Reject a list data validation that has no formula1 (#1303) 2026-10-14 19:31:01 +02:00
Marco Arnulfo f36bc100d4 AutoFit measures each distinct text once, and quickly on large columns (#1316) 2026-10-14 18:14:09 +02:00
Marco Arnulfo f92b4d30cb AutoFit Column Width measures text in the font the grid draws it with (fixes #1316) 2026-10-14 18:01:11 +02:00
midasmatterhorn 450ef1aea0 engine: refuse removeDuplicates with no valid column instead of deleting all rows 2026-10-14 18:01:42 +03:00
dev-exeandClaude Opus 5.5 2cae3a22b1 Slicers: a table named in table is found on any sheet
insert.slicer and table.insertSlicer looked for the table named by `table` only on the active
sheet. With another sheet active, the call failed with "Select a cell inside a table first, or
pass `table`" although `table` was passed. Other table commands find a table by name in the
whole workbook (Workbook::table).

The named table is now found on any sheet, and the slicer goes on that table's sheet. `at` is
read on that sheet, so "Sheet1!G2" names the table's own sheet. A name that matches no table
gives "no table named `X`".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:30:46 +05:30
Pranav Chandra 3177fbe03f ui: show spill-cell formula as greyed out 2026-10-14 20:23:44 +05:30
MarekSpodBiedry 139098e174 Filter: count empty text and ="" results as (Blanks) (#1294) 2026-10-14 15:44:31 +02:00
Harrison BoothandClaude Opus 5.5 efd081acc0 UI parity: form dialogs open ready to type
Column Width, Row Height and the other form dialogs now open with the cursor
in their first field and its value selected, as in Excel: Alt+H, O, W, then
12 and Enter sets the width without clicking into the field first.

- The first text or number field takes focus once it can (a new window's
  first frame is a sizing pass that ignores focus requests), and its value
  is selected so typing replaces it.
- Number fields commit while typing, so Enter or OK in the same frame no
  longer confirms the old value.

Tests: tests/dialog_focus.rs (Column Width and Row Height: type, Enter,
value applied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:07:59 -05:00
derechopiano 78ffd3b4c3 fix(pdf): preserve exported links and allow CLI PDF conversion (#954) 2026-10-14 13:40:16 +01:00
Marco Arnulfo 39602e6ce1 Filter and Subtotal: a Sheet! prefix on the range works on that sheet, not the active one (refs #1283) 2026-10-14 13:00:17 +02:00
Marco Arnulfo 13691e95e5 Remove Duplicates: a Sheet! prefix on the range works on that sheet, not the active one (refs #1283) 2026-10-14 12:51:05 +02:00
Marco Arnulfo be1af2d6ac Sort: a Sheet! prefix on the range sorts that sheet, not the active one (fixes #1283) 2026-10-14 12:08:41 +02:00
Marco Arnulfo 531d292930 N() converts a computed array element by element, so it works inside SUMPRODUCT (fixes #1279) 2026-10-14 11:46:48 +02:00
RichardandClaude Fable 5.1 34aa98870d Zoom dialog: Excel's layout, current zoom pre-selected, Fit selection (#930)
View > Zoom is now one Magnification column as in Excel: 200%, 100%, 75%,
50%, 25%, Fit selection and Custom with its percentage box. The current zoom
is pre-selected (Custom when it matches no preset), the box shows whole
percentages clamped to 10..400 and typing in it selects Custom. Fit
selection runs view.zoomToSelection with the grid's size, the same answer
as the ribbon's Zoom to Selection. The window is 220 px wide instead of
380; its height is left to the shared dialog sizing work (#978).

Magnification and Fit selection are translated in every catalog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr
2026-10-14 20:22:00 +11:00
derechopiano 72db972de3 fix(engine): bound Undo history by retained cell storage (#1227) 2026-10-14 08:23:57 +01:00
Marco Arnulfo 89439636d8 Dialogs move by their title bar instead of staying pinned to the centre (fixes #1250) 2026-10-14 08:08:41 +02:00
RichardandClaude Opus 5.5 07f6fe0b0b Headers: mark hidden columns and rows, split cursor and double-click to unhide (#1247)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr
2026-10-14 16:52:24 +11:00
Marco Arnulfo 736cc89b2b Opening an XLSX with a part over 512 MB says how to get the data in (fixes #1242) 2026-10-14 07:44:41 +02:00
Marco Arnulfo d1075c067b BAHTTEXT: a final one after the millions reads เอ็ด (fixes #1234) 2026-10-14 05:46:44 +02:00
allenyllee 938097f5b6 I1231: load Flatpak host fonts for native CJK fallback 2026-10-14 11:44:01 +08:00
Marco Arnulfo 9f11e26e78 Journal: cap by serialized size and keep a recording's start through trims (part of #1228) 2026-10-14 05:10:19 +02:00
Marco Arnulfo f519fd67f6 XLSX: keep a dxf's protection, font size and face, and the font parts it turns off (part of #1209) 2026-10-14 04:49:33 +02:00
RichardandClaude Opus 5.5 b5aacceb11 Sheet tabs: right-clicking a tab activates its sheet
As in Excel, a right-click on an inactive tab now activates that sheet, so
the tab menu's Protect/Unprotect Sheet and Select All Sheets act on the
sheet that was clicked rather than the one that was active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr
2026-10-14 13:36:51 +11:00
Marco Arnulfo 3f520a3420 File: Save a Copy asks where to save a bare name instead of writing it to the working directory (fixes #1212) 2026-10-14 03:03:33 +02:00
Marco Arnulfo 5e5e131be7 Grid: an armed Format Painter pastes onto the next cells clicked or dragged (fixes #1211) 2026-10-14 03:03:28 +02:00
RichardandClaude Opus 5.5 d95908f4ba Ribbon tooltips show each platform's shortcuts (#1191)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:26:06 +11:00
RichardandClaude Opus 5.5 c74e1a4d99 fix(ui): menu items highlight under the pointer (#1174)
The cell and header right-click menus, the ribbon dropdown menus and the data
validation pick list drew their items with Button::frame(false), which turns
the background off in every state, hover included. frame_when_inactive(false)
keeps them flat at rest and shows the hover background, as Excel's menus do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:29:01 +11:00
vangel76andClaude Sonnet 5.5 48696f9bcf XLSX: [Content_Types].xml is the first zip entry, workbook parts precede document properties (#1090)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:44:20 +02:00
vangel76andClaude Sonnet 5.5 568dd58264 Charts: a huge trendline forecast no longer collapses the chart (#1148)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:13:05 +02:00
vangel76andClaude Sonnet 5.5 1539500b7f Conditional formats: statistics only for rules that read them, borrowed not cloned per cell (#1098)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:11:02 +02:00
MarekSpodBiedry a0b96d9920 Size the list validation dropdown to up to 8 rows (#1089) 2026-10-13 18:08:45 +02:00
MarekSpodBiedry 2effccc26c fix(ui): commit a pointed formula on the sheet it was started on (#996) 2026-10-13 18:07:23 +02:00
MarekSpodBiedry 71437f23b1 Fix table without header: re-point references when the data moves down (#1030) 2026-10-13 18:06:31 +02:00
MarekSpodBiedry e440c8bdc9 Read date text arguments in the workbook date system (#1038) 2026-10-13 18:06:29 +02:00
MarekSpodBiedry a091fd9b50 CSV export: check the size cap before each field, not after the row (#1023) 2026-10-13 18:05:34 +02:00
MarekSpodBiedry 2c8ff394cc Refuse a cut-paste whose destination does not fit instead of dropping cells (#997) 2026-10-13 18:04:55 +02:00
MarekSpodBiedry 3580a72c0d Respect workbook structure protection in sheet rename, move, copy, hide and unhide (#1049) 2026-10-13 18:04:32 +02:00
MarekSpodBiedry 66be0a8646 Open notice: do not promise to keep macros that were dropped (#1024) 2026-10-13 18:04:22 +02:00
MarekSpodBiedry 8939e7817f Fix xlsx: combine escaped UTF-16 surrogate pairs when decoding strings (#1025) 2026-10-13 18:04:09 +02:00
MarekSpodBiedry 01032e9010 Cap CSV import fields per record at the worksheet column limit (#1037) 2026-10-13 18:03:54 +02:00
MarekSpodBiedry 29c38887de Keep chart cache point positions when a reference has blank edges (#1015) 2026-10-13 18:03:44 +02:00
MarekSpodBiedry 08902d925a xlsx: keep the first group's series kind when importing a combination chart (#1014) 2026-10-13 18:03:35 +02:00
MarekSpodBiedry caa9fd0f48 Fix row keys merging distinct text rows in UNIQUE and grouping (#1012) 2026-10-13 18:02:34 +02:00
jonathanwxh-cell 18092672ee #701: integrate hidden-sheet navigation with current main 2026-10-14 00:01:18 +08:00
ZaxaerithandCodex 2b53301092 fix(grid): keep cursor stable during cell dragging (#936)
Co-Authored-By: Codex <noreply@openai.com>
2026-10-13 23:14:56 +08:00
mbielski1337 694809d6c5 i18n: register Polish (pl) language 2026-10-13 17:02:21 +02:00
mbielski1337 5effeea8fc i18n: add Polish translation table 2026-10-13 17:01:15 +02:00
Lexx143andClaude Fable 5.1 209d2447d9 Charts: multi-level category axes, read, drawn and written back
A category range two or more columns wide beside a column of values (year beside month, Excel's
multiLvlStrRef) was read as one flat list: 36 rows in two columns became 72 categories, the
series sat in the left half of the plot and the years and months shared one row of labels.
Saving wrote the range as a plain strRef, so Excel lost the two-level axis too.

- gridcraft_core::category_levels: the shape of a category range beside its values (levels are
  columns when the points run down the rows, rows when they run along the columns; up to three
  outer levels), shared by the chart and xlsx crates.
- ChartData.category_levels: the outer levels, nearest the labels first, one entry per category,
  non-empty where a group starts. resolve fills it; a single column is unchanged.
- render: the outer levels in rows under the labels, a group's name centred under its categories
  with a separator at each boundary; in bar charts in columns beside the labels.
- xlsx: <c:cat> written as multiLvlStrRef with the level caches (innermost first), so the axis
  survives a round trip.

Tests in core (shapes), chart (resolve and layout for columns, bars and a row of values) and
xlsx (written XML, read-back, stability, a single column stays strRef). chart-parity, gaps § 4
and the ROADMAP progress log updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:30:01 +03:00
scottcampbelldata 00fb1147c3 Fix PR 451: integrate main and validate existing behavior 2026-10-13 10:03:52 -04:00
NetterSilver dcef8f12b2 Docs: update the progress docs for this change 2026-10-13 16:02:06 +02:00
NetterSilver d3cc40ca20 Colour menus: More Colors… and Recent Colors (#928)
The palette every colour menu shares ends with the custom colours last used
(Recent Colors, newest first, ten at most, each once) and a More Colors… button
that opens a picker in the menu: the saturation/value area with its hue bar, egui's
red-green-blue boxes, a hex box (#RRGGBB, RRGGBB, #RGB) with the new colour beside
it, OK and Cancel. OK applies the colour like a swatch and remembers it.

The ribbon's Fill Color and Font Color menus close only on a click outside them or
after a pick, so the picker stays open while it is used; the Format Cells pickers
show it inline. The tab-colour and chart-series menus are sub-menus that close on
any click inside, so they show Recent Colors but not the picker.
2026-10-13 16:02:06 +02:00
Marco Arnulfo 2d0a7a1e42 Ribbon: more currency symbols in the Accounting Number Format menu (fixes #1063) 2026-10-13 14:48:32 +02:00
quantmind-br 482c3e9481 Engine: formulas.evaluate refuses what cell entry refuses, saying why
`formulas.evaluate` (behind `gridcraft-cli eval` and MCP's `evaluate_formula`) turned any
formula that didn't parse into #NAME?, so `=SUM(1;2)` looked like an unknown function. It now
parses the way `cell.set` does (missing closing parentheses are added) and returns the same
errors: "There's a problem with this formula: expected `,` or `)`, found Semicolon", or the
wrong-argument-count message. An unknown function still evaluates to #NAME?, as in Excel.
2026-10-13 09:38:41 -03:00
Marco Arnulfo f8838b15e1 Keyboard: an arrow that commits typing ends the run of Tabs, as in Excel
Typing then Right or Down committed through the Enter/Tab path, so Right
continued the run and Down went back to its starting column. Found by
@richard1912 against Excel 365: from B2, type, Tab, type, Right, Enter
lands on D3, and with Down on C4.
2026-10-13 14:32:23 +02:00
pourie-he c05285e181 UI: support Command and Shift clicks in formula pointing 2026-10-13 23:12:38 +11:00
nihilist-narwhal 93338d06ce LAMBDA helpers: #CALC! for nested arrays, #VALUE! for wrong parameter counts
MAP, BYROW, BYCOL, SCAN and MAKEARRAY put one value in each cell, but a
LAMBDA returning several values silently gave its first one:
MAP({1,2},LAMBDA(x,{1,2})) was {1,1} and BYROW(…,LAMBDA(r,r)) the first
column. As in Excel, the whole result is now #CALC!, as arrays can't
nest (a single-value array is still fine). MAP with a LAMBDA taking a
different number of parameters than it has arrays, and BYROW or BYCOL
with one taking other than one, ran anyway; Excel gives #VALUE!.
2026-10-13 06:54:46 -05:00
nihilist-narwhal 9939819ee3 rows/cols parameters: accept a single row or column
home.insertRows {"rows":"3"} and home.insertColumns {"cols":"B"}
failed with "not a range": only spans such as 3:3 or B:B parsed. The
same went for deleting, hiding, sizing and grouping rows and columns.
A single row number or column letter now means that one line.
2026-10-13 06:54:31 -05:00
nihilist-narwhal 260da4e752 Recalc: an array that spills as before doesn't requeue its dependents
After each round, every array formula that was evaluated queued the
dependents of its spill area again, even when it spilled the same area
with the same values. Two arrays that read each other's spill areas
then queued each other in every round, up to the limit of 8: one edit
evaluated 24 formulas instead of 3. An unchanged spill now leaves its
dependents alone. last_recalc_cells counts the formulas of all rounds.
2026-10-13 06:54:21 -05:00
nihilist-narwhal 785913ed47 Flash Fill: read the text cells show
Flash Fill read stored values, so a date was the serial number 45306,
not 2024-01-15: with "15/01/2024" given for 2024-01-15 it found no
part of the source in the example and filled every row with
"15/01/2024" again. It now reads the cells' displayed text, as Excel
does.
2026-10-13 06:54:20 -05:00
nihilist-narwhal a98239e1d1 Format Cells: take the documented camelCase style keys
home.formatCells documents {style: {numFmt?, ...}}, but the style patch
was merged with the style's snake_case field names, so numFmt (and
borders.diagUp/diagDown) were silently ignored. camelCase keys in the
patch are now renamed to the style's own; snake_case keys still work.
2026-10-13 06:54:20 -05:00
Marco Arnulfo 4d5360fd90 Sheet tabs: Ctrl-drag (Cmd or Option on macOS) drops a copy of the sheet (fixes #971) 2026-10-13 12:21:52 +02:00
Marco Arnulfo 3272097e15 Control: Go To and Select over the control channel scroll the active cell into view (fixes #966) 2026-10-13 12:02:55 +02:00
RichardandClaude Opus 5.5 4bcc5c87d1 Ribbon: Font Size box matches the Font name box, list drops under it
The size box is one frame with the Font name box's fill, border, height and
corners, with the text field and the dropdown arrow inside it (focus shows
the selection stroke). The size list drops directly below the box and is as
wide as it, instead of opening 220 pt wide under the arrow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut3hr3a13szpQdQ7gbYbfc
2026-10-13 20:09:54 +11:00
Anirudh Sharma 8f9338a5ec fix(fill): respect workbook date system in monthly autofill 2026-10-13 14:21:00 +05:30
Marco Arnulfo 5a7fbfa5b8 Contributors: add my real name 2026-10-13 10:46:07 +02:00
Marco Arnulfo 4ec6a373b0 Ribbon: Font Size is a box you type into, the sizes behind its arrow (fixes #932) 2026-10-13 10:35:59 +02:00
Marco Arnulfo f644004cc6 Sheet tabs: New sheet (+) right after the last tab, the strip capped at 60% (part of #943) 2026-10-13 10:35:14 +02:00
Marco Arnulfo a54cf58f2e Keyboard: Enter after a run of Tabs goes to the next row in the column the run started from (fixes #911) 2026-10-13 07:48:23 +02:00
Marco Arnulfo 06c8ff640d Keytips: Alt, F opens the File view, and its F letters still run (part of #893) 2026-10-13 07:31:28 +02:00
Marco Arnulfo d0d4d26c74 UI: File › Account shows the version, platform and licence, with an About GridCraft button (fixes #896) 2026-10-13 07:12:34 +02:00
Marco Arnulfo 9943e78952 Merge remote-tracking branch 'origin/main' into fix/close-unsaved-prompt 2026-10-13 06:26:10 +02:00
Marco Arnulfo d231823e1c Merge remote-tracking branch 'origin/main' into fix/name-union-definition 2026-10-13 06:22:09 +02:00
CouchRivandClaude Opus 5.5 7b19b74a59 Size commands: a null sheet is no sheet
`{"sheet": null, "cols": "Data!B:B"}` was refused as naming a sheet other than `sheet`,
while `target_sheet` and the cell parameters read a null `sheet` as absent. The prefix now
decides, as it does when `sheet` is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:37:45 -04:00
CouchRivandClaude Opus 5.5 2a580f2902 Conditional formats: a style fill of "none" means no fill
`style: {fill: "none"}` (or "auto") went through the colour pass and came out as a solid
fill in the automatic colour. `home.fillColor` reads the same word as no fill, so the rule
style does too now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:34:14 -04:00
CouchRivandClaude Opus 5.5 1a6979c948 Data validation: refuse an unknown type, operator or error style
data.validation took any `type`, `operator` or `errorStyle` and fell back
to a default: a typo such as `type: "lst"` saved an "any value" rule
(`type="none"`), which validates nothing, and an unknown operator became
`between`. Each is now an error that lists the accepted values; `any`
stays a type, and the operator aliases (`greaterThan`…) still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:32:31 -04:00
CouchRivandClaude Opus 5.5 180928c001 Data validation: a list typed without quotes is saved as a quoted list
data.validation {type: "list", formula1: "Open,In progress,Closed"}, which is
how Excel's Source box takes a list, worked inside GridCraft but was saved as
<formula1>Open,In progress,Closed</formula1>. In a file an inline list is a
string literal, "Open,In progress,Closed" (what Excel writes and what our own
reader expects); unquoted, formula1 reads as a formula of names.

- validation quotes such a source when the rule is created, doubling inner
  quotes, using the test list_items already used to tell a literal list from
  a reference or a name (now is_literal_list). A source typed with = (a
  formula), a reference, a name, a sheet-qualified range and an already quoted
  list are stored as before.
- list_items reads a quoted list as a string literal: "" inside stands for
  one quote, so an item with a quote reads back the same after the change and
  from a file.

Creating the rule is the one place that still knows whether the user typed =,
so the XLSX writer is left alone: it can't tell OFFSET(...) entered as a
formula from a literal list, and a rule read from a file is already quoted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:08:25 -04:00
CouchRivandClaude Opus 5.5 151a279da8 Conditional formats: hex colours in a custom style, no silently empty rules
home.conditionalFormat deserialized `style` straight into the model's
Style, so a programmatic caller had to write colours as {"Rgb": 16773836}:
"#FFF2CC", which format_range, colorScale and dataBar take, was refused
with a serde message naming internal types. A key the struct doesn't have
(`style: {preset: …}`, `fillColor`) was dropped, which saved a rule with an
empty format that matches and shows nothing. And a cellIs `value` already
written as a string literal ("\"Closed\"", as in the file and openpyxl)
was quoted again into the text "Closed" with its quotes, so it never
matched.

`style` colours (font, fill fg/bg, borders) may now be "#RRGGBB",
{theme, tint} or "auto", and `fill` may be a colour alone (a solid fill);
a fill colour with no pattern is solid. Unknown keys and a non-object
`style` are refused. A cellIs value that is one string literal is kept.
The model's own shape keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:00:09 -04:00
CouchRivandClaude Opus 5.5 9a816f80f7 Format: Row Height and Column Width say pixels, and take points like Excel
home.rowHeight and home.columnWidth take pixels at 96 dpi, the model's unit
(header drags and the Row Height dialog send pixels), but their parameter docs
said points. An agent asking for a 30 pt row got `ht="22.5"` in the file, and
the range check accepted 500 while its message said "between 0 and 409 points".

- The params docs say pixels: `height: 20 (pixels) | points: 15`,
  `width: 64 (pixels) | chars: 8.43`, and `{width: 64 (pixels)}` for
  home.defaultWidth. Pixel values behave as before.
- home.rowHeight takes `points`, Excel's unit (Row Height dialog, `ht` in the
  file), as home.columnWidth already takes `chars`.
- Range errors give the limit in the unit passed: 546 pixels (409.5 points),
  409.5 points, 1800 pixels, 255 characters. `chars` is checked against 255,
  Excel's limit and the widest column a file holds (it went to 256.4 through
  the pixel limit).
- The model's size comments say pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:54:25 -04:00
CouchRivandClaude Opus 5.5 46b2fe2f7a Row, column and freeze commands act on the sheet they are given
view.freezePanes, home.rowHeight, home.columnWidth, the AutoFit, Hide and
Unhide row/column commands and home.defaultWidth always changed the active
sheet: a `sheet` parameter was silently ignored, so a programmatic caller
laying out a sheet that isn't active changed the wrong one, and `Sheet!`
prefixes on `rows`/`cols` were refused as "not a range".

They now take `sheet` (name or index) like the commands that already use
target_sheet, accept a `Sheet!` prefix on `rows`/`cols` (and on Freeze
Panes' `cell`, which was refused), and refuse a prefix that contradicts
`sheet`. The selection belongs to the active sheet, so another sheet needs
explicit lines or a cell. Freeze Top Row, Freeze First Column and Unfreeze
Panes take `sheet` too. Without `sheet`, nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:50:48 -04:00
Marco Arnulfo f25bded4ef Closing the window asks to save unsaved workbooks (fixes #838, fixes #841) 2026-10-13 00:12:20 +02:00
Marco Arnulfo cf587ffe3b Names: a name over several ranges reads its top-level commas as unions (fixes #728) 2026-10-12 19:16:49 +02:00
jonathanwxh-cell 561299bfad sheet-navigation: keep boundary navigation on visible worksheets 2026-10-12 22:59:53 +08:00
MarekSpodBiedry da1d9d0116 Cap queued pointer events from ui.click/ui.drag control requests (#525) 2026-10-12 08:36:28 +02:00
MarekSpodBiedry 194a1204ab Open the Header & Footer dialog on the sheet's saved header and footer (#523) 2026-10-12 08:31:43 +02:00
MarekSpodBiedry 0019c2c552 Make check --json list warning messages instead of a count (#526) 2026-10-12 08:31:39 +02:00
MarekSpodBiedry c45212ac68 Refuse inserting cells that would push values off the worksheet (#580) 2026-10-12 08:27:27 +02:00
prxnvt 76c9f4f2af Build: the ui-egui build script reads CARGO_MANIFEST_DIR when it runs
env! fixes the path to contributors/ when the build script is compiled.
After a checkout is moved or copied, the compiled script keeps looking in
the old location: the About window loses its contributor list, and
because the rerun-if-changed path no longer exists, Cargo reruns the
script and rebuilds the UI crate on every build. Reading the variable at
run time follows the checkout.
2026-10-11 23:29:44 -04:00
Dejan PortandClaude Sonnet 5 7ba3676f5f fix: satisfy stricter clippy lints CI's newer stable toolchain catches
CI failed with cargo clippy --workspace --all-targets -- -D warnings,
not a test. The actual cause: I'd been validating locally against
rustc/clippy 1.95.0 (the toolchain this machine had installed before
now), while CI installs plain "stable" (1.99.0 as of this run) — two
lints new since 1.95.0 fired there and nowhere in my local checks:

- clippy::int_plus_one on `src.end.row + 1 <= cap` in grid.rs,
  simplified to `src.end.row < cap` (same meaning, no overflow risk
  change: row is a u32 and cap is already saturating-clamped below
  MAX_ROWS - 1, so src.end.row + 1 can't wrap).
- clippy::disallowed_methods on the new fill_handle_perf example's
  three Instant::now() calls — the project's own lint from the time-
  panic fix (#125 history): reading the clock traps on wasm. This
  example is native-only (a `cargo run --release` benchmark, never
  built for wasm), so it gets the same #![allow(...)] perf.rs already
  carries, with the same justification.

Updated the local toolchain to stable (rustup update stable) and
re-ran cargo clippy --workspace --all-targets -- -D warnings clean
before this push, to actually match what CI checks instead of
guessing again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 02:52:07 +02:00
Dejan PortandClaude Sonnet 5 e7d590c8a4 style: satisfy cargo fmt and clippy on the fill-handle perf change
cargo fmt reformatted the new example's while-loop condition onto its
own line; clippy's nonminimal_bool caught a negated is_some_and that
reads more plainly as is_none_or.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 02:07:45 +02:00
Dejan PortandClaude Sonnet 5 43a118c543 perf: fill-handle double-click scans a dense neighbour column ~2.4x faster
Follow-up to #210, which the reviewer correctly rejected: the stated
cause (a sparse column with a stray distant value) doesn't hold, since
the scan already stops at the first empty cell. This is a different,
narrower fix that doesn't change behaviour for any input — it never
truncates a long fill, unlike #210's cap — only how fast a genuinely
long, densely-populated run is scanned.

grid.rs's fill-handle double-click called Sheet::value_ref once per
row to find where the neighbouring column's data ends. Each call does
a band lookup + row lookup + column lookup (three BTreeMap probes) via
CellStore::get. For a real dense run (e.g. a filled column near the
1,048,576-row sheet limit) that's a lot of point lookups on the UI
thread in one frame: ~59ms for a 1,000,000-row run in a release build
(measured with the new fill_handle_perf example), likely worse under
wasm where GridCraft actually runs in the browser.

- crates/model/src/store.rs: CellStore::contiguous_value_run walks
  each band's row BTreeMap with `range` (one in-order pass per band)
  instead of one `get` per row, so a dense run costs roughly one
  tree-traversal step per row instead of one binary search per row.
  Deliberately narrower than `has`/`get`: a stored cell always decides
  the row on its own (even with an empty value), matching what
  `value_ref`'s callers actually test for, not `has`'s "formula counts
  regardless of its cached value" (used for Ctrl+arrow navigation).
- crates/model/src/sheet.rs: Sheet::last_contiguous_nonempty layers
  `spill` back in wherever contiguous_value_run finds no stored cell,
  exactly where plain `value_ref` would fall through to it.
- crates/ui-egui/src/grid.rs: the fill-handle double-click now calls
  this once instead of looping over value_ref.

Result: 999,999-row dense run, 59.2ms -> 24.7ms (2.40x), same answer
(asserted in the example). Four new tests in sheet.rs, including a
300-trial property test against a naive value_ref-loop reference
(spills, formula-with-empty-value, gaps, blanks) — all pass, plus the
full existing suite (`cargo test -p gridcraft-model -p gridcraft-ui-egui
-p gridcraft-engine`; the only failures are #210-era gridcraft-engine
CSV tests already failing on main, unrelated to this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 02:01:49 +02:00
345 changed files with 32534 additions and 3516 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
-176
View File
@@ -1,176 +0,0 @@
name: Publish Flatpak repository to Pages
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: "Published GitHub release tag to publish (for example, v1.2.3)"
required: true
type: string
permissions:
contents: read
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
build:
name: Build Flatpak repository
# Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
# here, and the validation step below refuses a manual tag with a pre-release suffix.
if: github.event_name != 'release' || !github.event.release.prerelease
runs-on: ubuntu-24.04
permissions:
contents: read
# configure-pages only reads the Pages configuration (base_url); deploy needs write.
pages: read
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.gridcraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
# A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
case "${tag%%+*}" in
*-*)
echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
exit 1
;;
esac
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="gridcraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/gridcraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/gridcraft-$version-linux-$arch.flatpak"
done
# Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
# (re-mirrored and re-deployed on every release) does not grow without bound.
flatpak build-update-repo \
--prune --prune-depth=3 \
--title="GridCraft" \
--comment='Official GridCraft releases' \
--description='Official Flatpak repository for GridCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/gridcraft.flatpakrepo" <<META
[Flatpak Repo]
Title=GridCraft
Comment=Official GridCraft releases
Description=Official Flatpak repository for GridCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
flatpak install gridcraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+13 -81
View File
@@ -1,95 +1,27 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# Test builds run only from the `release` branch (a manual run from any other branch is skipped).
# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run
# from a branch builds the tarball too; they need no secrets.
#
# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the
# release binaries and uploads `gridcraft-<version>-freebsd-x86_64.tar.gz` as the
# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the
# draft GitHub Release.
name: FreeBSD
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
inputs:
package:
description: "Build the release tarball instead of running the tests"
type: boolean
default: false
workflow_call:
inputs:
version:
description: "Version for the artifact name (empty: Cargo.toml)"
type: string
default: ""
version_override:
description: "Also write the version into Cargo.toml before building (test runs)"
type: boolean
default: false
package:
type: boolean
default: false
concurrency:
# Called from release.yml, github.workflow is the caller's name; keep the groups apart.
group: freebsd-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.workflow == 'FreeBSD' }}
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }})
if: github.ref == 'refs/heads/release' || inputs.package
runs-on: ubuntu-latest
timeout-minutes: 300
env:
GRIDCRAFT_VERSION: ${{ inputs.version }}
GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }}
GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA"
# Only the packaging run brings files back (dist/release). The target dir lives outside
# the synced checkout, because copying it out of the VM fails on large builds.
copyback: ${{ inputs.package }}
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0
rustc --version
if [ -n "$GRIDCRAFT_PACKAGE" ]; then
if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then
cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
fi
CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh
else
cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
fi
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: inputs.package
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+2 -1
View File
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
+1 -1
View File
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
Generated
+27 -16
View File
@@ -619,6 +619,15 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "cipher"
version = "0.5.2"
@@ -1496,9 +1505,10 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"arboard",
"chrono",
"craft-print",
"eframe",
"egui",
@@ -1518,7 +1528,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1530,7 +1540,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1541,7 +1551,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1553,8 +1563,9 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"chrono",
"js-sys",
"serde",
"serde_json",
@@ -1563,7 +1574,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1584,7 +1595,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-core",
"serde",
@@ -1593,7 +1604,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-core",
"regex",
@@ -1601,7 +1612,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-engine",
"log",
@@ -1610,7 +1621,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1622,18 +1633,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.7.0"
version = "0.11.1"
[[package]]
name = "gridcraft-ui-egui"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"craft-ui",
"egui",
@@ -1655,7 +1666,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"eframe",
"egui",
@@ -1672,7 +1683,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"aes",
"gridcraft-core",
@@ -4526,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.11.1"
dependencies = [
"gridcraft-engine",
"serde_json",
+5 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.11.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -30,6 +30,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.41"
@@ -66,9 +68,11 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
+23 -18
View File
@@ -7,14 +7,18 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png" width="128" height="128" alt="GridCraft icon: a spreadsheet page on a green tile">
</p>
<h1 align="center">GridCraft</h1>
<p align="center">
<b>Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust.</b>
<b>Spreadsheets and data; an open-source, high-performance modern spreadsheet application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source, clean-room take on the Excel workflow. It runs natively on macOS,
A fast, open-source, clean-room take on modern spreadsheet workflows. It runs natively on macOS,
Windows, Linux and BSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
</p>
@@ -24,6 +28,9 @@
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-1f9d55?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-147a40?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-1f9d55?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_gridcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/gridcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -67,8 +74,8 @@
<td width="50%" valign="top"><img src="docs/images/grades-formulas.png" alt="A gradebook table with averages and letter grades computed with IFS, green highlights on A grades and data bars, with the Formulas ribbon tab open showing the function library" width="100%"><p align="center"><sub><b>Formulas.</b> 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.</sub></p></td>
</tr>
<tr>
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
<td width="50%" valign="top"><img src="docs/images/pivot.png" alt="A PivotTable summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the PivotTable Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas" width="100%"><p align="center"><sub><b>PivotTables.</b> Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.</sub></p></td>
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="The sales dashboard in dark mode with the Insert ribbon tab open, showing Pivot Table, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
<td width="50%" valign="top"><img src="docs/images/pivot.png" alt="A pivot table summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the Pivot Table Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas" width="100%"><p align="center"><sub><b>Pivot tables.</b> Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.</sub></p></td>
</tr>
<tr>
<td width="50%" valign="top"><img src="docs/images/format-chart.png" alt="The sales dashboard with the column chart selected: resize handles on the chart, its source data outlined in the table, the Chart Design tab open and the Format Chart Area pane on the right" width="100%"><p align="center"><sub><b>Charts.</b> Seventeen chart types, Chart Design tab and a Format pane; selecting a chart outlines its source data.</sub></p></td>
@@ -78,19 +85,19 @@
## Why GridCraft
- **Familiar.** Excel's ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill
handle, point-mode formula entry with coloured references, Format Cells, Paste Special,
conditional formatting, tables, charts and more. If you know Excel, you already know GridCraft.
- **Familiar.** Ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill handle,
point-mode formula entry with coloured references, Format Cells, Paste Special,
conditional formatting, tables, charts and more. Everything is right where you expect it.
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
PivotTables and print settings round-trip. CSV and TSV too.
Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet).
pivot tables and print settings round-trip. CSV and TSV too.
Legacy 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet).
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
structured table references, Excel's full number-format language, sort and AutoFilter,
structured table references, a comprehensive number-format language, sort and AutoFilter,
Flash Fill, data validation, outlining and subtotals.
- **Fast.** Copy-on-write workbooks (undo snapshots are nearly free), incremental recalculation,
and a virtualised grid that only lays out what's on screen.
@@ -119,7 +126,7 @@ cd apps/gridcraft-web && trunk build --release # → dist/web (serve it with
cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample)
```
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the
browser's file picker (dropping files works too) and Save downloads the workbook.
### Ribbon access keys
@@ -187,7 +194,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) |
| L3 | `chart` (toolkit-free chart layout and rendering) |
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
| L6 | `ui-egui` (spreadsheet UI, control channel) · `mcp` (MCP server) |
| L7 | `apps/gridcraft`, `apps/gridcraft-cli`, `apps/gridcraft-web` |
- **Contributor and agent rules** (clean-room, asset policy, never-crash, quality gates): [`AGENTS.md`](AGENTS.md)
@@ -197,9 +204,9 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
## Roadmap
GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file
compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions
([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we
compatibility are still rough. It covers 89% of standard ribbon commands in our catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 standard worksheet functions
([`docs/function-parity.md`](docs/function-parity.md)); this is a parity inventory, not a count of working implementations. Weighted by what spreadsheet users rely on, we
estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large
workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage,
numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [`docs/gaps.md`](docs/gaps.md).
@@ -236,7 +243,7 @@ bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The
web backend defaults are unchanged.
**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no
graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every
graphics card with a driver for it, it only offers the Windows-provided software renderer, which draws every
frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if
the first renderer fails to start, it tries the other one once. The log file records the choice
(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is
@@ -334,8 +341,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and GridCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft, Excel and Microsoft 365 are trademarks or registered trademarks of Microsoft Corporation in the United States and/or other countries. GridCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation; these names are used only to describe the workflows it is compatible with.</sub>
<br>
<p align="center">
+50 -5
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -21,6 +21,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
@@ -93,7 +94,9 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
added 10-11), the
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
(#38). Japanese (10-17) and Brazilian Portuguese also translate the dialog titles and labels,
message boxes, Backstage/File-view items, template names and native menus: 943 strings each, ~43%.
The remaining dialogs, menus and messages are English; formulas and number formats are en-US only.
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Status | UI strings |
@@ -104,14 +107,14 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
| Hindi | hi | none | 0% |
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
| French | fr | none | 0% |
| Portuguese | pt-BR | menus only | ~12% |
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
| Indonesian | id | none | 0% |
| Japanese | ja | menus only | ~12% |
| Japanese | ja | menus, File view, dialogs, messages | ~43% |
| German | de | menus only | ~12% |
| Korean | ko | menus only | ~12% |
| Vietnamese | vi | none | 0% |
Other shipped languages: 2 (Russian and German, menus only, ~12%).
Other shipped languages: 3 (Russian and German, menus only, ~12%; Dutch, menus, File view and dialog titles, ~24%).
## Upcoming
@@ -132,6 +135,31 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) |
| 2026-10-19 | Linux: "Use system setting" for the theme keeps following the desktop when the XDG Desktop Portal stops answering: each read gives up after 2 s and the next poll reconnects, where one unanswered call stalled the watcher for the rest of the session |
| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB |
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip |
| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148)
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +221,23 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
| 2026-10-19 | trivial | Progress log: desktop theme portal read timeout |
| 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes |
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) |
| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row |
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
+115 -36
View File
@@ -139,9 +139,10 @@ impl RoundTrip {
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FileReport {
pub path: String,
/// GridCraft couldn't open the file: its message.
/// Opening the file or discovering the requested inputs failed: its message.
pub error: Option<String>,
pub warnings: usize,
/// Import warnings, in the order the importer produced them.
pub warnings: Vec<String>,
pub calc: CalcReport,
pub round_trip: RoundTrip,
}
@@ -204,67 +205,112 @@ pub fn main(args: &[String]) -> ExitCode {
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
}
}
let files = collect_inputs(&a.positional);
let reports: Vec<FileReport> = files
.iter()
.map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
Ok(bytes) => check_bytes(&path, &bytes, &opts),
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
}
})
.collect();
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
reports.extend(files.iter().map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
Ok(bytes) => check_bytes(&path, &bytes, &opts),
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
}
}));
if a.has("json") {
outln!("{}", pretty(&report_json(&reports, &opts)));
} else {
print_human(&reports, &opts);
}
if reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
if !files.is_empty() && reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
}
#[derive(Default)]
struct Discovery {
files: Vec<PathBuf>,
failures: Vec<FileReport>,
}
impl Discovery {
fn fail(&mut self, path: &Path, error: String) {
self.failures.push(FileReport { path: path.to_string_lossy().into_owned(), error: Some(error), ..Default::default() });
}
/// False means the file budget is exhausted: the remaining scan is incomplete.
fn add_file(&mut self, path: PathBuf) -> bool {
if self.files.len() >= MAX_FILES {
self.fail(&path, format!("file limit of {MAX_FILES} reached; this and remaining inputs were not checked"));
return false;
}
self.files.push(path);
true
}
}
/// Files named on the command line are checked whatever their extension; directories contribute
/// their `.xlsx`/`.xlsm` files, recursively, in name order. Hidden files and Excel lock files
/// (`~$…`) are skipped.
fn collect_inputs(inputs: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
fn collect_inputs(inputs: &[String]) -> Discovery {
let mut out = Discovery::default();
for i in inputs {
let p = PathBuf::from(i);
if p.is_dir() {
walk(&p, 0, &mut out);
} else {
// A missing path is reported as a file that failed to open.
out.push(p);
}
if out.len() >= MAX_FILES {
out.truncate(MAX_FILES);
if !walk(&p, 0, &mut out) {
break;
}
} else if !out.add_file(p) {
break;
}
}
if out.files.is_empty()
&& out.failures.is_empty()
&& let Some(input) = inputs.first()
{
out.fail(Path::new(input), "no workbook files were found in the requested inputs".into());
}
out
}
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth > MAX_DEPTH || out.len() >= MAX_FILES {
return;
/// Returns false only when the global file budget stops traversal.
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
if depth > MAX_DEPTH {
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
return true;
}
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
out.fail(dir, format!("cannot read directory: {e}"));
return true;
}
};
let mut entries = Vec::new();
for entry in rd {
match entry {
Ok(entry) => entries.push(entry.path()),
Err(e) => out.fail(dir, format!("cannot read directory entry: {e}")),
}
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if name.starts_with('.') || name.starts_with("~$") {
continue;
}
if p.is_dir() {
walk(&p, depth + 1, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
out.push(p);
}
if out.len() >= MAX_FILES {
return;
let metadata = match p.metadata() {
Ok(metadata) => metadata,
Err(e) => {
out.fail(&p, format!("cannot inspect input: {e}"));
continue;
}
};
if metadata.is_dir() {
if !walk(&p, depth + 1, out) {
return false;
}
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
&& !out.add_file(p)
{
return false;
}
}
true
}
/// Checks one file's bytes. Never fails: problems are recorded in the report. A panic escaping
@@ -287,7 +333,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
return report;
}
};
report.warnings = warnings.len();
report.warnings = warnings;
let mut fresh = cached.clone();
let mut calc = Calc::new();
calc.recalc_all(&mut fresh);
@@ -1270,6 +1316,30 @@ mod tests {
use super::*;
#[test]
fn discovery_reports_a_directory_read_error() {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
assert!(walk(&path, 0, &mut discovery));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
let _ = std::fs::remove_file(path);
}
#[test]
fn discovery_reports_omissions_only_after_the_file_limit() {
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
assert!(discovery.failures.is_empty());
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
assert_eq!(discovery.files.len(), MAX_FILES);
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
}
fn at(a1: &str) -> CellRef {
CellRef::parse(a1).unwrap()
}
@@ -1600,6 +1670,15 @@ mod tests {
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
}
#[test]
fn json_lists_warning_messages() {
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
let v = report_json(&[with, without], &Options::default());
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
assert_eq!(v["files"][1]["warnings"], json!([]));
}
#[test]
fn unopenable_files_are_reported() {
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
+59 -3
View File
@@ -36,13 +36,15 @@ use serde_json::{Value, json};
const USAGE: &str = "usage:
gridcraft-cli info <file> [--json] sheets, used ranges, tables, charts, names
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html/pdf by extension
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
[--out FILE] [--print RANGE] [--quiet]
a script line is id={json} or {\"command\": \"id\", \"params\": {…}};
blank lines and lines starting with # or // are skipped
gridcraft-cli commands [--json] [--search X]
gridcraft-cli functions [--json] [--search X] [--category C]
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
@@ -271,11 +273,17 @@ fn convert(args: &[String]) -> Result<(), String> {
let input = a.pos(0, "<in>")?;
let out = a.pos(1, "<out>")?;
a.no_extra(2)?;
if gridcraft_engine::io::FileKind::from_path(out).is_none() {
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)"));
let pdf = out.to_ascii_lowercase().ends_with(".pdf");
if !pdf && gridcraft_engine::io::FileKind::from_path(out).is_none() {
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json, .html or .pdf)"));
}
let mut s = open(input)?;
activate_sheet(&mut s, a.opt("sheet"))?;
if pdf {
let result = exec(&mut s, "file.exportPdf", json!({"path": out}))?;
eprintln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]);
return Ok(());
}
let d = s.doc().map_err(|e| e.to_string())?;
let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;
gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;
@@ -369,6 +377,17 @@ fn parse_command(text: &str) -> Result<Option<(String, Value)>, String> {
}
if t.starts_with('{') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?;
// A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and
// `{"command": …, "name": "Report"}` ran the command with no parameters at all and the
// script still exited 0, so a typo made a valid, wrong workbook (#2256).
if let Some(o) = v.as_object() {
let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect();
if !unknown.is_empty() {
unknown.sort_unstable();
let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `"));
return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}"));
}
}
let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?;
return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({})))));
}
@@ -561,6 +580,26 @@ mod tests {
assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({}))));
assert_eq!(parse_command(" # comment").unwrap(), None);
assert!(parse_command("x={bad").is_err());
// A key this does not read is a mistake: the command used to run with no parameters and
// the script still exited 0, so a typo made a valid, wrong workbook (#2256).
for (line, named) in [
(r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"),
(r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"),
(r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"),
] {
let e = parse_command(line).expect_err("refused");
assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}");
assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}");
}
// Several at once are listed in one message, sorted.
let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused");
assert!(e.contains("unknown keys `args`, `name`"), "{e}");
// `id` is still the accepted alternative to `command`, and `params` still carries them.
assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({}))));
assert_eq!(
parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(),
Some(("home.insertSheet".into(), json!({"name": "R"})))
);
}
#[test]
@@ -583,4 +622,21 @@ mod tests {
assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err());
assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err());
}
#[test]
fn convert_xlsx_to_pdf_keeps_links() {
let dir = std::env::temp_dir().join(format!("gridcraft-cli-pdf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("links.xlsx");
let output = dir.join("links.pdf");
let mut s = blank();
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org"})).unwrap();
let bytes = gridcraft_engine::io::save_file_bytes(&s.doc().unwrap().wb, input.to_str().unwrap()).unwrap();
gridcraft_engine::io::write_file(input.to_str().unwrap(), &bytes).unwrap();
convert(&[input.to_string_lossy().into_owned(), output.to_string_lossy().into_owned()]).unwrap();
let pdf = std::fs::read(&output).unwrap();
assert!(pdf.starts_with(b"%PDF-"));
assert!(pdf.windows(b"/URI (https://example.org)".len()).any(|w| w == b"/URI (https://example.org)"));
std::fs::remove_dir_all(dir).unwrap();
}
}
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
@@ -174,3 +172,73 @@ fn check_exit_codes_and_json() {
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
}
#[test]
fn check_rejects_an_empty_scan() {
let dir = tmpdir("check-empty");
std::fs::write(dir.join("notes.txt"), "not a workbook").unwrap();
std::fs::write(dir.join(".hidden.xlsx"), "ignored").unwrap();
std::fs::write(dir.join("~$locked.xlsx"), "ignored").unwrap();
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(report["files"][0]["error"].as_str().unwrap().contains("no workbook files"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
let dir = tmpdir("check-depth");
let book = dir.join("readable.xlsx");
ok(&["run", "--out", book.to_str().unwrap(), "--quiet"]);
let mut deep = dir.clone();
for _ in 0..34 {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("skipped.xlsx"), "not reached").unwrap();
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["totals"]["opened"], 1);
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
let _ = std::fs::remove_dir_all(dir);
}
/// A script line whose parameters are under a key `run` does not read used to run the command
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
#[test]
fn a_script_line_with_an_unknown_key_stops_the_run() {
let dir = tmpdir("script-keys");
let script = dir.join("s.jsonl");
let out = dir.join("t.xlsx");
let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap();
write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]);
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
let err = String::from_utf8_lossy(&o.stderr);
assert!(!o.status.success(), "the run must stop: {err}");
assert!(err.contains("s.jsonl:2"), "it names the line: {err}");
assert!(err.contains("unknown key `args`"), "and the key: {err}");
assert!(!out.exists(), "and writes no workbook: {err}");
// The forms that are read still work, in one script: `params`, `id=json`, and a comment.
write(&[
r#"{"command":"file.new"}"#,
r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#,
"# a comment",
r#"home.insertSheet={"name":"Report2"}"#,
]);
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = ok(&["info", out.to_str().unwrap()]);
assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}");
assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}");
// The usage text says what a line looks like, which is how this was found.
let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout);
assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}");
let _ = std::fs::remove_dir_all(&dir);
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, one module per file, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod text_to_columns_quotes;
+1 -1
View File
@@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GridCraft</title>
<link data-trunk rel="icon" href="../../assets/app-icon/gridcraft.ico" />
<link data-trunk rel="rust" data-bin="gridcraft-web" data-wasm-opt="z" data-weak-refs />
+4 -1
View File
@@ -11,11 +11,14 @@
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments.
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
//! `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(target_arch = "wasm32")]
mod web;
+162
View File
@@ -0,0 +1,162 @@
//! Preferences between visits (#1562): what the desktop app keeps in `ui.json` (theme, interface
//! size, language, ribbon state…) and `prefs.json` (editing options, user name…), kept in the
//! browser's `localStorage` under [`UI_KEY`] and [`PREFS_KEY`]. Restored at startup; written back
//! whenever they change, since a reload or a closed tab runs no exit code.
use gridcraft_ui_egui::SheetApp;
/// The `localStorage` key for the desktop's `ui.json`.
pub const UI_KEY: &str = "gridcraft.ui";
/// The `localStorage` key for the desktop's `prefs.json`.
pub const PREFS_KEY: &str = "gridcraft.prefs";
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
const MAX_STORED: usize = 1 << 20;
/// Apply preferences stored on an earlier visit, as the desktop app reads `ui.json` and
/// `prefs.json`. Missing, oversized or unreadable values leave what the app starts with.
pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
let readable = |s: &&str| s.len() <= MAX_STORED;
if let Some(s) = ui.filter(readable)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(s)
&& let Ok(state) = serde_json::from_value::<gridcraft_ui_egui::UiState>(json.clone())
{
// No usable saved language: keep the browser's, as the desktop keeps the system's.
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
app.ui = state;
app.ui.language = language;
}
if let Some(s) = prefs.filter(readable)
&& let Ok(p) = serde_json::from_str(s)
{
app.session.prefs = p;
}
}
/// Tracks what was last written, so the store is written only when a preference changed.
#[derive(Default)]
pub struct PrefsSync {
ui: Option<String>,
prefs: Option<String>,
/// The user name to store in place of the session's (see [`Self::keep_user_name`]).
user_name: Option<String>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(ui: Option<String>, prefs: Option<String>) -> Self {
Self { ui, prefs, user_name: None }
}
/// Stores `name` as the user name whatever the session's becomes: under `?host=parent` the
/// framing page names the author for this visit only, and must not replace the user's own.
pub fn keep_user_name(&mut self, name: String) {
self.user_name = Some(name);
}
/// The `(key, JSON)` pairs to store because they differ from what was last written.
pub fn changed(&mut self, app: &SheetApp) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
let prefs = match &self.user_name {
Some(name) => serde_json::to_string(&gridcraft_engine::Prefs { user_name: name.clone(), ..app.session.prefs.clone() }),
None => serde_json::to_string(&app.session.prefs),
};
for (key, last, json) in [(UI_KEY, &mut self.ui, serde_json::to_string(&app.ui)), (PREFS_KEY, &mut self.prefs, prefs)] {
match json {
Ok(json) if last.as_deref() != Some(json.as_str()) => {
*last = Some(json.clone());
out.push((key, json));
}
Ok(_) => {}
Err(e) => log::warn!("preferences: cannot serialize {key}: {e}"),
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use gridcraft_engine::Session;
use gridcraft_ui_egui::Services;
use gridcraft_ui_egui::i18n::Language;
use serde_json::json;
fn app() -> SheetApp {
let mut app = SheetApp::new(Session::new(), Services::default());
// As `web::start` sets it up before restoring.
app.ui.system_theme = true;
app.ui.language = Language::De;
app
}
fn stored(writes: &[(&'static str, String)], key: &str) -> Option<String> {
writes.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone())
}
/// #1562: dark mode chosen on one visit is there after a reload, with the editing options.
#[test]
fn dark_mode_survives_reload() {
let mut first = app();
let mut sync = PrefsSync::new(None, None);
assert_eq!(sync.changed(&first).len(), 2, "first frame stores the preferences");
assert!(sync.changed(&first).is_empty(), "unchanged preferences are not rewritten");
first.run("view.theme", json!({"mode": "dark"})).unwrap();
first.session.prefs.enter_direction = "right".into();
let writes = sync.changed(&first);
let (ui, prefs) = (stored(&writes, UI_KEY), stored(&writes, PREFS_KEY));
assert!(ui.is_some() && prefs.is_some(), "both changes are stored: {writes:?}");
let mut reloaded = app();
restore(&mut reloaded, ui.as_deref(), prefs.as_deref());
assert!(reloaded.ui.dark && !reloaded.ui.system_theme, "dark mode is back");
assert_eq!(reloaded.session.prefs.enter_direction, "right");
assert!(PrefsSync::new(ui, prefs).changed(&reloaded).is_empty(), "restoring doesn't write back");
}
/// `?host=parent`: the author the framing page names is not stored over the user's own name.
#[test]
fn host_author_is_not_stored() {
let mut first = app();
first.session.prefs.user_name = "Ada".into();
let mut sync = PrefsSync::new(None, None);
let stored_prefs = stored(&sync.changed(&first), PREFS_KEY);
let mut hosted = app();
restore(&mut hosted, None, stored_prefs.as_deref());
let mut sync = PrefsSync::new(None, stored_prefs);
sync.keep_user_name(hosted.session.prefs.user_name.clone());
hosted.session.prefs.user_name = "Host Author".into();
hosted.session.prefs.r1c1 = true;
let writes = sync.changed(&hosted);
let prefs = stored(&writes, PREFS_KEY).expect("other preferences are still stored");
let mut reloaded = app();
restore(&mut reloaded, None, Some(&prefs));
assert_eq!(reloaded.session.prefs.user_name, "Ada");
assert!(reloaded.session.prefs.r1c1);
}
/// A stored `ui` without a language keeps the browser's, as the desktop keeps the system's.
#[test]
fn missing_language_keeps_the_browsers() {
let mut a = app();
restore(&mut a, Some(r#"{"dark":true}"#), None);
assert!(a.ui.dark);
assert_eq!(a.ui.language, Language::De);
restore(&mut a, Some(r#"{"language":"fr"}"#), None);
assert_eq!(a.ui.language, Language::Fr);
}
#[test]
fn junk_storage_keeps_defaults() {
let big = format!("{{\"dark\":true,\"recent\":[\"{}\"]}}", "x".repeat(MAX_STORED));
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"dark\":42}"), Some(big.as_str())] {
let mut a = app();
restore(&mut a, junk, junk);
assert!(a.ui.system_theme && !a.ui.dark, "{junk:?}");
assert_eq!(a.ui.language, Language::De);
assert_eq!(a.session.prefs.enter_direction, "down");
}
}
}
+82 -15
View File
@@ -20,6 +20,17 @@ pub fn start() {
return;
};
let mut options = eframe::WebOptions::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_options.wgpu_setup {
let default_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
let mut descriptor = default_descriptor(adapter);
descriptor.required_limits = descriptor.required_limits.or_worse_values_from(&adapter.limits());
descriptor
});
}
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
@@ -58,14 +69,20 @@ pub fn start() {
) {
app.ui.language = browser;
}
// Preferences from an earlier visit (#1562), as the desktop app reads `ui.json`
// and `prefs.json`; a saved language replaces the browser's.
let (stored_ui, stored_prefs) = (stored(crate::prefs::UI_KEY), stored(crate::prefs::PREFS_KEY));
crate::prefs::restore(&mut app, stored_ui.as_deref(), stored_prefs.as_deref());
let mut prefs = crate::prefs::PrefsSync::new(stored_ui, stored_prefs);
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
if let Some(bridge) = &app_bridge {
prefs.keep_user_name(app.session.prefs.user_name.clone());
if let Some(author) = query_param("author") {
crate::host::Bridge::set_author(&mut app, &author);
}
bridge.post_ready();
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
}),
)
.await;
@@ -93,6 +110,16 @@ struct WebShell {
app: SheetApp,
inbox: Inbox,
bridge: Option<crate::host::Bridge>,
prefs: crate::prefs::PrefsSync,
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
/// A value stored on an earlier visit, if the browser allows storage.
fn stored(key: &str) -> Option<String> {
local_storage()?.get_item(key).ok()?
}
impl eframe::App for WebShell {
@@ -113,6 +140,20 @@ impl eframe::App for WebShell {
});
}
self.app.logic(ctx);
// Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).
let changed = self.prefs.changed(&self.app);
if !changed.is_empty() {
match local_storage() {
Some(store) => {
for (key, json) in changed {
if let Err(e) = store.set_item(key, &json) {
log::warn!("preferences: cannot save {key}: {e:?}");
}
}
}
None => log::warn!("preferences: browser storage is unavailable"),
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -224,15 +265,20 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
iframe.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/pdf");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
iframe.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?;
if let Err(e) = body.append_child(&iframe) {
cleanup_print(&iframe, &url);
return Err(js(e));
}
// `onload` fires once the PDF has actually rendered inside the iframe;
// printing before that would show a blank page. Set the handler BEFORE
@@ -240,22 +286,44 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> {
let iframe_for_load = iframe.clone();
let url_for_cleanup = url.clone();
let on_load = wasm_bindgen::closure::Closure::once_into_js(move || {
let Some(content_window) = iframe_for_load.content_window() else { return };
let _ = content_window.print();
// `afterprint` fires once the print dialog (or print preview) closes, whether the user
// printed or cancelled — the right moment to release the blob URL and drop the iframe.
let iframe_for_cleanup = iframe_for_load.clone();
let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url_for_cleanup).ok();
iframe_for_cleanup.remove();
});
let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref());
iframe_for_load.set_onload(None);
if let Err(e) = print_loaded_pdf(&iframe_for_load, &url_for_cleanup) {
cleanup_print(&iframe_for_load, &url_for_cleanup);
log::error!("print failed: {e:?}");
}
});
iframe.set_onload(Some(on_load.unchecked_ref()));
iframe.set_src(&url);
Ok(())
}
fn cleanup_print(iframe: &web_sys::HtmlIFrameElement, url: &str) {
// Both operations are idempotent: print can fail after dispatching `afterprint`.
web_sys::Url::revoke_object_url(url).ok();
iframe.remove();
}
fn print_loaded_pdf(iframe: &web_sys::HtmlIFrameElement, url: &str) -> Result<(), JsValue> {
let content_window = iframe.content_window().ok_or_else(|| JsValue::from_str("no print content window"))?;
let iframe_for_cleanup = iframe.clone();
let url_for_cleanup = url.to_owned();
let on_after_print = wasm_bindgen::closure::Closure::once(move || {
cleanup_print(&iframe_for_cleanup, &url_for_cleanup);
});
// Listen before `print()`, which can dispatch `afterprint` before it returns.
// A one-shot listener also releases the callback when the dialog closes or is cancelled.
let options = web_sys::AddEventListenerOptions::new();
options.set_once(true);
let callback = on_after_print.as_ref().unchecked_ref();
content_window.add_event_listener_with_callback_and_add_event_listener_options("afterprint", callback, &options)?;
if let Err(e) = content_window.print() {
let _ = content_window.remove_event_listener_with_callback("afterprint", callback);
return Err(e);
}
let _ = on_after_print.into_js_value();
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -274,7 +342,6 @@ fn mime_for(name: &str) -> &'static str {
}
/// Trigger a browser save dialog or download of `bytes` named after `name`.
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
+1
View File
@@ -18,6 +18,7 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
+191 -34
View File
@@ -1,7 +1,11 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
//!
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
@@ -13,6 +17,13 @@ const MAX_LINE: usize = 64 * 1024 * 1024;
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let rx = serve_listener(listener, ctx)?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
@@ -21,47 +32,193 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlR
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
})?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// 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> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
return Ok(true);
}
if buf.len() > max {
return Err(format!("request too large (over {max} bytes); closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// One request line, parsed.
enum Line {
Request {
id: Json,
method: String,
params: Json,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
if is_http_request_line(text) {
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Json>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Json::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request {
id: obj.get("id").cloned().unwrap_or(Json::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let reader = BufReader::new(read);
for line in reader.lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
if line.len() > MAX_LINE {
let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"}));
continue;
}
let msg: Json = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
continue;
}
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => break,
// The peer is gone before sending a line: nobody to tell.
Err(_) if buf.is_empty() => break,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let (req, rx) = ControlRequest::new(method, params);
let state = std::sync::Arc::clone(&req.state);
if tx.send(req).is_err() {
break;
}
// Wake the UI thread: egui only runs frames when something happens.
ctx.request_repaint();
let mut reply = rx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| {
// Give up on the request as well as on its reply. While it is still
// queued this keeps it from running late -- the client was told it had
// failed, and an agent that retried after the error ran it twice (#1914);
// if the app had already started it, its outcome is unknown instead.
if ControlRequest::abandon(&state) {
json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})
} else {
json!({"ok": false, "error": "timed out waiting for the UI, which had already started the request, so it may still finish; its outcome is unknown"})
}
});
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{reply}").is_err() {
break;
}
continue;
}
},
};
let id = msg.get("id").cloned().unwrap_or(Json::Null);
let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
break;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(input).unwrap();
// A read timeout ends the list too: the server kept the connection open.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
}
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, egui::Context::default()).unwrap())
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = serve_on_loopback();
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n";
let replies = exchange(addr, http);
assert_eq!(replies.len(), 1, "{replies:?}");
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
let (addr, rx) = serve_on_loopback();
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true}));
}
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
let mut r = BufReader::new(c);
for id in [1, 2] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["id"], id, "{line}");
}
// Wake the UI thread: egui only runs frames when something happens.
ctx.request_repaint();
let mut reply = rx
.recv_timeout(Duration::from_secs(60))
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let mut input = junk.to_vec();
input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n");
let replies = exchange(addr, &input);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
}
if writeln!(out, "{reply}").is_err() {
break;
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
#[test]
fn an_overlong_line_is_refused_without_reading_the_rest() {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
}
+36 -19
View File
@@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -393,6 +399,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+1 -1
View File
@@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Unhide Sheet…", "sheet.unhide", None),
("-", "", None),
("Conditional Formatting…", "dialog:manageRules", None),
("Format as Table", "insert.table", None),
("Format as Table", "home.formatAsTable", None),
],
),
(
+33 -2
View File
@@ -60,8 +60,39 @@ sync_github_name = true
[people.jsymes21-byte]
real_name = "James Symes"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.Shnrqpdr]
sync_github_name = true
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.pourie-he]
sync_github_name = true
[people.Stefanfra]
real_name = "Stefan Fransen"
sync_github_name = true
[people.allenyllee]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.Harrison-Booth]
real_name = "Harrison Booth"
sync_github_name = false
[people.bilalmughal]
sync_github_name = true
[people.tyrel]
real_name = "Tyrel Haveman"
+160 -52
View File
@@ -13,10 +13,14 @@ use gridcraft_model::Workbook;
/// Largest array a reference may expand to.
pub const MAX_CELLS: u64 = 16_000_000;
/// References up to this many cells are read at their full size; larger ones (`A:A`, ranges
/// reserved for data to come such as `$A$2:$A$50000`) stop at the sheet's used range, so that
/// formulas over them stay fast.
/// References up to this many cells (one whole row) are always read at their full size.
const FULL_SIZE_CELLS: u64 = 16_384;
/// Bounded references up to this many cells (one column's worth) are read at their full size too, as
/// Excel does, so their shape lines up with the arrays next to them (`D3:AZ400*B3:B400` is 398 by
/// 49 even when the data ends at column F) and their blanks count. References that run to the edge
/// of the sheet (`A:A`, `A2:A1048576`, several whole rows) and larger blocks stop at the sheet's used
/// range, so that formulas over them stay fast.
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
const MAX_DEPTH: usize = 400;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -90,6 +94,8 @@ struct FnCtx<'a, 'h> {
ev: &'a mut Evaluator<'h>,
/// The criteria argument of a database function, when it is a reference.
criteria: Option<Area>,
/// The LAMBDA given as the function argument of GROUPBY/PIVOTBY.
lambda: Option<Arc<Lambda>>,
}
impl Ctx for FnCtx<'_, '_> {
@@ -124,6 +130,19 @@ impl Ctx for FnCtx<'_, '_> {
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
}
fn has_lambda(&self) -> bool {
self.lambda.is_some()
}
/// A LAMBDA must give one value per group; an array result is #CALC!.
fn call_lambda(&mut self, arg: Value) -> Option<Value> {
let l = self.lambda.clone()?;
let [p] = l.params.as_slice() else { return Some(Value::Error(CellError::Value)) };
let r = self.ev.call_lambda_values(&l, vec![(p.clone(), Ev::V(arg))]);
Some(match self.ev.deref(r) {
Value::Array(a) if a.data.len() != 1 => Value::Error(CellError::Calc),
v => v.scalar(),
})
}
}
impl<'h> Evaluator<'h> {
@@ -154,8 +173,13 @@ impl<'h> Evaluator<'h> {
Ok(self.resolve_sheet(&r.sheet)?.into_iter().map(|sheet| Area { sheet, range }).collect())
}
/// Dereferences: one cell → its value; a block → an array (trimmed to the used range);
/// several areas → `#VALUE!`.
/// Dereferences: one cell → its value; a block → an array; several areas → `#VALUE!`.
///
/// A block keeps its full shape (blanks included) when it holds at most `FULL_SIZE_CELLS`
/// cells, or when it holds at most `FULL_SIZE_BOUNDED_CELLS` cells (one full column's worth)
/// and does not run to the sheet's last row or column. Every other block (larger, or a
/// bigger one that runs to the sheet's edge, such as `A:A` or `1:1`) is trimmed to the
/// sheet's used range before its values are read. An array over `MAX_CELLS` is `#NUM!`.
pub fn deref(&mut self, ev: Ev) -> Value {
match ev {
Ev::V(v) => v,
@@ -174,7 +198,11 @@ impl<'h> Evaluator<'h> {
// A reference keeps its size, so the array has its shape and its blanks count
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
let range = if cells > FULL_SIZE_CELLS { self.trim(a) } else { a.range };
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
// the column", not for a block of a chosen size.
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
let range = if full_size { a.range } else { self.trim(a) };
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
@@ -256,13 +284,13 @@ impl<'h> Evaluator<'h> {
}
fn name(&mut self, n: &str) -> Ev {
if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) {
if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)) {
return v.clone();
}
let wb = self.host.workbook();
// Sheet-qualified `Sheet1!Name`.
let (scope_sheet, bare) = match n.split_once('!') {
Some((s, b)) => match wb.sheet_index(s.trim_matches('\'')) {
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
Some(i) => (i, b),
None => return err(CellError::Name),
},
@@ -271,7 +299,7 @@ impl<'h> Evaluator<'h> {
if let Some(def) = wb.name(bare, scope_sheet) {
let text = def.formula.clone();
let scope = def.scope.unwrap_or(self.sheet);
return match gridcraft_formula::parse(&text) {
return match gridcraft_formula::parse_name(&text) {
Ok(expr) => {
// Relative references in a name are relative to A1: used here, they move by
// this cell's offset from it.
@@ -618,7 +646,7 @@ impl<'h> Evaluator<'h> {
let value = self.value(a);
evaluated.push(Arg { value, from_ref: false });
}
let mut ctx = FnCtx { ev: self, criteria: None };
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
}
let i = match index.to_number() {
@@ -772,7 +800,7 @@ impl<'h> Evaluator<'h> {
Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() }))
}
"ISOMITTED" => {
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
Ev::V(Value::Bool(r))
}
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
@@ -817,8 +845,21 @@ impl<'h> Evaluator<'h> {
let mut criteria_shape = None;
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
let mut criteria = None;
// GROUPBY/PIVOTBY: the LAMBDA given as their function.
let mut lambda = None;
let lambda_at = match name {
"GROUPBY" => Some(2),
"PIVOTBY" => Some(3),
_ => None,
};
for (i, a) in args.iter().enumerate() {
let mut ev = self.eval(a);
if lambda_at == Some(i)
&& let Ev::L(l) = &ev
{
lambda = Some(l.clone());
ev = Ev::V(Value::Empty);
}
if spec.category == gridcraft_functions::Category::Database
&& i == 2
&& let Ev::R(areas) = &ev
@@ -853,7 +894,7 @@ impl<'h> Evaluator<'h> {
};
evaluated.push(Arg { value, from_ref });
}
let mut ctx = FnCtx { ev: self, criteria };
let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
@@ -907,13 +948,13 @@ impl<'h> Evaluator<'h> {
};
let args = [Arg { value, from_ref }];
if kind != Agg::Count {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.sum += n,
other => return Err(Ev::V(other)),
}
}
if kind != Agg::Sum {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
other => return Err(Ev::V(other)),
}
@@ -975,7 +1016,7 @@ impl<'h> Evaluator<'h> {
/// that name.
fn function_by_name(&self, e: &Expr) -> Option<String> {
let Expr::Name(n) = e else { return None };
if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() {
if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() {
return None;
}
let u = n.to_ascii_uppercase();
@@ -1012,15 +1053,18 @@ impl<'h> Evaluator<'h> {
let arrays: Vec<Arc<Array>> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
if (rows * cols) as u64 > MAX_CELLS {
return err(CellError::Num);
let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) };
// The LAMBDA takes one parameter per array.
if l.params.len() != arrays.len() {
return err(CellError::Value);
}
let mut out = Vec::with_capacity(rows * cols);
let mut out = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect();
let v = self.call_lambda_values(&l, bound);
out.push(self.deref(v).scalar());
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
@@ -1041,7 +1085,8 @@ impl<'h> Evaluator<'h> {
let r = self.call_lambda_values(&l, bound);
acc = self.deref(r);
if name == "SCAN" {
scan.push(acc.scalar());
let Some(one) = single(acc.clone()) else { return err(CellError::Calc) };
scan.push(one);
}
}
if name == "SCAN" {
@@ -1057,6 +1102,9 @@ impl<'h> Evaluator<'h> {
let arr = as_array(self.value(&args[0]));
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
if l.params.len() != 1 {
return err(CellError::Value);
}
let by_row = name == "BYROW";
let n = if by_row { arr.rows } else { arr.cols };
let mut out = Vec::with_capacity(n);
@@ -1068,7 +1116,8 @@ impl<'h> Evaluator<'h> {
};
let a = if by_row { Array::row(slice) } else { Array::column(slice) };
let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]);
out.push(self.deref(r).scalar());
let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) };
out.push(one);
}
let a = if by_row { Array::column(out) } else { Array::row(out) };
Ev::V(a.into())
@@ -1097,7 +1146,8 @@ impl<'h> Evaluator<'h> {
(l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))),
];
let v = self.call_lambda_values(&l, bound);
out.push(self.deref(v).scalar());
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
out.push(one);
}
}
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
@@ -1318,7 +1368,7 @@ impl<'h> Evaluator<'h> {
}
}
}
let mut ctx = FnCtx { ev: self, criteria: None };
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
Value::Number(p) if p >= 1.0 => {
let k = p as u32 - 1;
@@ -1373,7 +1423,16 @@ impl<'h> Evaluator<'h> {
}))
}
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
"filename" => Ev::V(Value::text(String::new())),
// Excel's `<folder>/[<file>]<sheet>`, which the classic sheet-name formula splits on
// the `]`. Empty until the workbook has a file of its own, as in Excel (#1489).
"filename" => Ev::V(Value::text(match &wb.source_path {
Some(file) => {
let name = file.file_name().unwrap_or_default().to_string_lossy();
let on = sheet.map_or("", |s| s.name.as_str());
file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned()
}
None => String::new(),
})),
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
"format" => {
@@ -1422,43 +1481,74 @@ impl<'h> Evaluator<'h> {
if name == "AGGREGATE" && func >= 14 {
k = rest.get(1).map(|e| self.value(e));
}
// Every cell visited (even one skipped as hidden, nested or an error) and every value
// counts against one budget for the whole call. All arguments are evaluated (references
// stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes
// `A:XFD` trim to the whole sheet, ~17 billion cells (#2175).
enum Input {
Cells(usize, RangeRef),
Values(Value),
}
let mut inputs = Vec::new();
let mut budget = MAX_CELLS;
for e in refs {
match self.eval(e) {
Ev::R(areas) => {
for a in areas {
let range = self.trim(&a);
for c in range.iter() {
let (hidden, nested) = {
let wb = self.host.workbook();
let s = wb.sheet(a.sheet);
let hidden =
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
let nested = skip_nested
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
let u = f.text.to_ascii_uppercase();
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
});
(hidden, nested)
};
if hidden || nested {
continue;
}
let v = self.host.cell_value(a.sheet, c);
if skip_errors && v.is_error() {
continue;
}
values.push(v);
}
budget = match budget.checked_sub(range.count()) {
Some(left) => left,
None => return err(CellError::Num),
};
inputs.push(Input::Cells(a.sheet, range));
}
}
Ev::V(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
Ev::V(v) => values.push(v),
Ev::V(v) => {
let n = match &v {
Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX),
_ => 1,
};
budget = match budget.checked_sub(n) {
Some(left) => left,
None => return err(CellError::Num),
};
inputs.push(Input::Values(v));
}
Ev::L(_) => return err(CellError::Value),
}
}
for input in inputs {
match input {
Input::Cells(sheet, range) => {
for c in range.iter() {
let (hidden, nested) = {
let wb = self.host.workbook();
let s = wb.sheet(sheet);
let hidden =
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
let nested = skip_nested
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
let u = f.text.to_ascii_uppercase();
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
});
(hidden, nested)
};
if hidden || nested {
continue;
}
let v = self.host.cell_value(sheet, c);
if skip_errors && v.is_error() {
continue;
}
values.push(v);
}
}
Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
Input::Values(v) => values.push(v),
}
}
if let Some(k) = k {
values.push(Value::Empty);
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values[..values.len() - 1], &k));
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k));
}
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
@@ -1615,7 +1705,8 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
(Value::Array(x), Value::Array(y)) => {
let rows = broadcast_dim(x.rows, y.rows);
let cols = broadcast_dim(x.cols, y.cols);
let mut data = Vec::with_capacity(rows * cols);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
@@ -1629,6 +1720,13 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
}
}
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
}
fn broadcast_dim(a: usize, b: usize) -> usize {
if a == 1 {
b
@@ -1645,8 +1743,9 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
let (fr, fc) = dims(f);
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
let mut data = Vec::with_capacity(rows * cols);
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for col in 0..cols {
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
@@ -1655,6 +1754,15 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
Value::from(Array { rows, cols, data })
}
/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
fn single(v: Value) -> Option<Value> {
match v {
Value::Array(a) if a.rows * a.cols > 1 => None,
v => Some(v.scalar()),
}
}
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
+179 -9
View File
@@ -60,6 +60,12 @@ const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate
/// whatever order they come in, with bounded stack.
const MAX_NESTED: usize = 64;
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
@@ -395,6 +401,24 @@ struct PassHost<'a> {
cycle: bool,
cycles: Vec<Key>,
depth: usize,
/// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned
/// and it is calculated first.
suspend: Option<Key>,
/// Formulas whose evaluation was abandoned for another one, to be calculated again after it.
/// They all need it, so reading one of them while it is calculated is a circular reference,
/// as reading a formula in progress is.
waiting: HashSet<Key>,
/// The formulas abandoned since the last suspension.
abandoned: Vec<Key>,
/// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They
/// are calculated along with the suspended one, rather than one per restart (a wide range
/// read at the limit would restart the abandoned evaluations once per cell in it).
also: Vec<Key>,
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
#[cfg(test)]
suspensions: usize,
/// The formulas in `cycles`.
cycle_seen: HashSet<Key>,
/// Iterative calculation: a circular reference reads the cell's previous value instead of
/// giving `#CIRC!`.
iterate: bool,
@@ -425,6 +449,13 @@ impl<'a> PassHost<'a> {
cycle: false,
cycles: vec![],
depth: 0,
suspend: None,
waiting: HashSet::default(),
abandoned: Vec::new(),
also: Vec::new(),
#[cfg(test)]
suspensions: 0,
cycle_seen: HashSet::default(),
iterate: false,
prev: HashMap::default(),
stack: Vec::new(),
@@ -456,7 +487,65 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
}
/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over
/// `range`: the same area, nothing new in the way, and the same values.
fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool {
let end =
CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
RangeRef::new(anchor, end) == range
&& !sheet.merges.iter().any(|m| m.intersects(&range))
&& !sheet.tables.iter().any(|t| t.range.contains(anchor))
&& range
.iter()
.enumerate()
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
}
impl PassHost<'_> {
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
/// calculated from here, then the suspended ones again (reading the results kept so far).
fn calculate(&mut self, k: Key) {
// Formulas to calculate, innermost last, each with the ones abandoned to calculate the
// next.
let mut work: Vec<(Key, Vec<Key>)> = vec![(k, Vec::new())];
while let Some(&(top, _)) = work.last() {
self.compute(top);
match self.suspend.take() {
Some(next) => {
#[cfg(test)]
{
self.suspensions += 1;
}
let held = std::mem::take(&mut self.abandoned);
self.waiting.extend(held.iter().copied());
if let Some(entry) = work.last_mut() {
entry.1 = held;
}
// The other formulas read at the limit are calculated after `next`, before the
// held evaluations restart. Each has nothing held of its own, so the held ones
// are released only when the last of them is done.
let mut seen: HashSet<Key> = HashSet::default();
seen.insert(next);
for s in std::mem::take(&mut self.also) {
if seen.insert(s) {
work.push((s, Vec::new()));
}
}
work.push((next, Vec::new()));
}
None => {
work.pop();
if let Some(entry) = work.last_mut() {
for a in entry.1.drain(..) {
self.waiting.remove(&a);
}
}
}
}
}
}
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
let v = v.clone();
@@ -468,14 +557,29 @@ impl PassHost<'_> {
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
if self.in_progress.contains(&k) || self.depth > 2000 {
if self.suspend.is_some() {
// Unwinding to `calculate`: the value is thrown away. A formula read at the limit is
// calculated with the suspended one, so the evaluations are not restarted for each.
if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) {
self.also.push(k);
}
return Value::Error(CellError::Calc);
}
if self.in_progress.contains(&k) || self.waiting.contains(&k) {
self.cycle = true;
if self.iterate {
return self.circular_read(k);
}
self.cycles.push(k);
// Once each, though a suspended evaluation meets it again.
if self.cycle_seen.insert(k) {
self.cycles.push(k);
}
return Value::Error(CellError::Circ);
}
if self.depth >= MAX_NESTED {
self.suspend = Some(k);
return Value::Error(CellError::Calc);
}
self.in_progress.insert(k);
self.depth += 1;
let outer_taint = std::mem::replace(&mut self.taint, false);
@@ -487,6 +591,17 @@ impl PassHost<'_> {
let mut ev = Evaluator::new(self, k.0, k.1);
ev.value(&expr)
};
if self.suspend.is_some() {
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
self.depth -= 1;
self.in_progress.remove(&k);
self.abandoned.push(k);
if self.iterate {
self.stack.pop();
}
self.taint = outer_taint;
return Value::Error(CellError::Calc);
}
// A legacy array formula fills the range it was entered in.
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
let v = match legacy {
@@ -517,9 +632,11 @@ impl PassHost<'_> {
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
};
if a.rows > 1 || a.cols > 1 {
self.spills.insert(k, a.clone());
}
// A dynamic-array formula anchors a spill range even while its result is one
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
// returns a scalar never reaches this arm, so it does not become an anchor.
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
}
Value::Empty => Value::Number(0.0),
@@ -577,7 +694,7 @@ impl PassHost<'_> {
self.pending = set.clone();
self.spills.retain(|a, _| !set.contains(a));
for k in &again {
self.compute(*k);
self.calculate(*k);
}
passes += 1;
}
@@ -950,6 +1067,7 @@ impl Calc {
/// Evaluates the dirty set and writes results (and spills) back.
fn run(&mut self, wb: &mut Workbook, mut dirty: Vec<Key>, full: bool) {
let mut dynamic_again = !self.graph.dynamic.is_empty();
self.last_recalc_cells = 0;
for _round in 0..8 {
if dirty.is_empty() {
break;
@@ -969,7 +1087,7 @@ impl Calc {
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
host.iterate = wb.calc.iterative;
for k in &dirty {
host.compute(*k);
host.calculate(*k);
}
if host.iterate && !host.members.is_empty() {
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
@@ -984,7 +1102,7 @@ impl Calc {
eprintln!(" eval {} formulas {:.1} ms", results.len(), prof_now() - tp);
}
let tw = prof_now();
self.last_recalc_cells = results.len();
self.last_recalc_cells += results.len();
self.circular = cycles;
// Write back.
let mut spill_changes: Vec<Key> = Vec::new();
@@ -1004,6 +1122,14 @@ impl Calc {
if old.is_none() && new.is_none() {
continue;
}
// An array that spills exactly as before changes nothing its dependents read, so
// they aren't queued again. Otherwise two arrays that read each other's spill areas
// queue each other in every round, up to the round limit.
if let (Some(r), Some(arr)) = (old, new)
&& spills_as_before(sheet, k.1, r, arr)
{
continue;
}
if let Some(r) = old {
for c in r.iter() {
sheet.spill.remove(&c);
@@ -1023,6 +1149,13 @@ impl Calc {
if let Some(cell) = sheet.cells.get_mut(k.1) {
cell.value = Value::Error(CellError::Spill);
}
// Not blocked while the pass ran (it would have been #SPILL! there), so
// blocked by another array of this pass: the formulas that read this one,
// at its anchor or over the area it would have spilled into, read #SPILL!
// or empty cells now.
let mut deps = Vec::new();
self.graph.dependents_of_range(k.0, range, &mut deps);
spill_changes.extend(deps.into_iter().filter(|d| d != k));
} else {
for (i, c) in range.iter().enumerate() {
if c != k.1 {
@@ -1036,7 +1169,15 @@ impl Calc {
if let Some(r) = changed_range {
let mut deps = Vec::new();
self.graph.dependents_of_range(k.0, r, &mut deps);
spill_changes.extend(deps.into_iter().filter(|d| d != k));
// Not the anchor itself (that would read its own array), unless its old array
// is gone or partly freed: its new formula may have read the old values, still
// laid out while it was evaluated.
let freed = match (old, sheet.spill_ranges.get(&k.1)) {
(Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)),
(_, None) => true,
(None, Some(_)) => false,
};
spill_changes.extend(deps.into_iter().filter(|d| d != k || freed));
}
if let Some(o) = old
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
@@ -1322,6 +1463,35 @@ pub(crate) mod tests {
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
}
/// A formula at the nesting limit reading thousands of formulas not calculated yet suspends the
/// evaluations in progress once, not once per formula read.
#[test]
fn wide_read_at_the_nesting_limit_suspends_once() {
const WIDTH: u32 = 3000;
let mut wb = Workbook::new();
let mut exprs = HashMap::default();
let mut put = |wb: &mut Workbook, at: String, text: String| {
let at = CellRef::parse(&at).unwrap();
wb.sheet_mut(0).unwrap().cells.set(at, Cell::formula(Formula::new(&text)));
exprs.insert((0, at), std::sync::Arc::new(gridcraft_formula::parse(&text[1..]).unwrap()));
};
// A1 reads A2, ..., A64 (the 64th formula deep) reads a wide range of formulas.
for r in 1..64 {
put(&mut wb, format!("A{r}"), format!("=A{}+1", r + 1));
}
put(&mut wb, "A64".to_string(), format!("=SUM(B1:B{WIDTH})"));
for r in 1..=WIDTH {
put(&mut wb, format!("B{r}"), format!("={r}*2"));
}
let mut rng = 1u64;
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0);
host.calculate((0, CellRef::parse("A1").unwrap()));
let total = (WIDTH * (WIDTH + 1)) as f64;
assert_eq!(host.results.get(&(0, CellRef::parse("A1").unwrap())), Some(&Value::Number(total + 63.0)));
assert!(host.cycles.is_empty());
assert!(host.suspensions <= 3, "{} suspensions", host.suspensions);
}
/// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the
/// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been
/// calculated yet, or `None` when it declines the block.
+515
View File
@@ -67,6 +67,25 @@ fn spill_range_operator() {
assert_eq!(t.num("E1"), 100.0);
}
#[test]
fn arrays_that_spill_as_before_do_not_requeue_their_dependents() {
// Two arrays that each read the other's spill area, and a formula reading both spills.
let mut t = T::new();
t.set("A1", "=SEQUENCE(2)+0*B2");
t.set("B1", "=SEQUENCE(2)+0*A2");
t.set("C1", "=SUM(A1#)+SUM(B1#)");
// An edit that spills the same values again: one round, not one per round up to the limit.
t.set("A1", "=SEQUENCE(2)+0*B2+0");
assert_eq!((t.num("A2"), t.num("B2"), t.num("C1")), (2.0, 2.0, 6.0));
assert!(t.calc.last_recalc_cells <= 3, "{} formulas evaluated for one edit", t.calc.last_recalc_cells);
// Only the anchor's value changes: formulas reading the whole spill still see it.
t.set("A1", "={7;2}+0*B2");
assert_eq!((t.num("A1"), t.num("C1")), (7.0, 12.0));
// A spill that does change still reaches its dependents.
t.set("A1", "=SEQUENCE(2)*10+0*B2");
assert_eq!((t.num("A2"), t.num("C1")), (20.0, 33.0));
}
#[test]
fn arithmetic_and_dependencies() {
let mut t = T::new();
@@ -310,6 +329,29 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
#[test]
fn lambda_helpers_check_shapes_and_parameters() {
let mut t = T::new();
let calc = Value::Error(CellError::Calc);
// A LAMBDA returning several values can't fill one cell: the whole result is #CALC!.
t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))");
assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty));
t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))");
assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty));
t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))");
assert_eq!(t.get("A4"), calc.clone());
t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))");
assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty));
// One parameter per array (MAP) or exactly one (BYROW/BYCOL).
t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))");
assert_eq!(t.get("A5"), Value::Error(CellError::Value));
t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))");
assert_eq!(t.get("A6"), Value::Error(CellError::Value));
// A single value in an array is fine.
t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))");
assert_eq!(t.num("B7"), 2.0);
}
#[test]
fn operators() {
let mut t = T::new();
@@ -368,6 +410,25 @@ fn spill_into_the_old_area_of_a_blocked_neighbour() {
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors() {
// Two arrays of one recalculation want the same empty cell: the one laid out second is
// #SPILL!, and the formulas that read it in that pass (at its anchor, through `A2#`, or over
// the cells it would have spilled into) kept its values.
let mut t = T::new();
t.set("B1", "=SEQUENCE(3)");
t.set("A2", "=SEQUENCE(1,3)");
t.set("D2", "=A2*10");
t.set("E2", "=C2");
t.set("F2", "=SUM(A2#)");
t.set("G2", "=SUM(C2:C3)");
// Both arrays recalculated together: B1 is laid out first and takes B2.
t.calc.recalc_all(&mut t.wb);
let spill = Value::Error(CellError::Spill);
assert_eq!((t.num("B2"), t.get("A2"), t.get("D2")), (2.0, spill.clone(), spill));
assert_eq!((t.get("C2"), t.num("E2"), t.get("F2"), t.num("G2")), (Value::Empty, 0.0, Value::Error(CellError::Ref), 0.0));
}
#[test]
fn arrays_do_not_spill_in_tables() {
// Excel: spilled array formulas aren't supported in tables.
@@ -543,6 +604,25 @@ fn spills() {
assert_eq!(t.num("D2"), 40.0);
}
#[test]
fn an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty() {
// The new formula was evaluated while the old array was still laid out, and only the
// anchor's dependents were evaluated again once it was gone.
let mut t = T::new();
t.set("A1", "=SEQUENCE(3)");
assert_eq!(t.num("A3"), 3.0);
t.set("A1", "=SUM(A2:A3)+1");
assert_eq!((t.num("A1"), t.get("A3")), (1.0, Value::Empty));
// Still an array, now spilling elsewhere: it read A2 as 2 and spilled three columns.
t.set("A1", "=SEQUENCE(3)");
t.set("A1", "=SEQUENCE(1,A2+1)");
assert_eq!((t.num("A1"), t.get("B1"), t.get("A2")), (1.0, Value::Empty, Value::Empty));
// A smaller array inside the old area: it read A3 as 3 and spilled over A1:A2.
t.set("A1", "=SEQUENCE(3)");
t.set("A1", "=SEQUENCE(MAX(1,A3-1))");
assert_eq!((t.num("A1"), t.get("A2"), t.get("A3")), (1.0, Value::Empty, Value::Empty));
}
#[test]
fn references_keep_their_size_past_the_used_range() {
let mut t = T::new();
@@ -590,6 +670,27 @@ fn let_lambda() {
assert_eq!(t.num("A4"), 14.0);
}
#[test]
fn let_lambda_names_ignore_case_in_any_script() {
let mut t = T::new();
t.set("A1", "=LET(dług,100,DŁUG*2)");
assert_eq!(t.num("A1"), 200.0);
t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()");
assert_eq!(t.get("A2"), Value::Bool(true));
t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)");
assert_eq!(t.num("A3"), 2.0);
t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))");
assert_eq!(t.num("A4"), 6.0);
t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))");
assert_eq!(t.num("A5"), 5.0);
// `ß` upper-cases to `SS`, yet `ẞ` is its capital.
t.set("A6", "=LET(ß,100,ẞ*2)");
assert_eq!(t.num("A6"), 200.0);
// Dotless ı is a different name from i.
t.set("A7", "=LET(i,1,ı,2,i)");
assert_eq!(t.num("A7"), 1.0);
}
#[test]
fn functions_given_by_name() {
let mut t = T::new();
@@ -611,6 +712,36 @@ fn functions_given_by_name() {
assert_eq!(t.num("J1"), 20.0);
}
#[test]
fn groupby_and_pivotby_take_a_lambda() {
let mut t = T::new();
for (i, (k, c, v)) in [("a", "x", 1), ("b", "x", 2), ("a", "y", 3)].iter().enumerate() {
t.set(&format!("A{}", i + 1), k);
t.set(&format!("B{}", i + 1), c);
t.set(&format!("C{}", i + 1), &v.to_string());
}
t.set("E1", "=GROUPBY(A1:A3,C1:C3,LAMBDA(x,SUM(x)))");
assert_eq!(t.get("E1"), Value::from("a"));
assert_eq!(t.num("F1"), 4.0);
assert_eq!(t.get("E2"), Value::from("b"));
assert_eq!(t.num("F2"), 2.0);
assert_eq!(t.get("E3"), Value::from("Total"));
assert_eq!(t.num("F3"), 6.0);
// A LET-bound lambda, called once per group with that group's values.
t.set("H1", "=LET(f,LAMBDA(v,ROWS(v)*10),GROUPBY(A1:A3,C1:C3,f))");
assert_eq!(t.num("I1"), 20.0);
assert_eq!(t.num("I3"), 30.0);
// PIVOTBY: rows a, b, Total; columns x, y, Total.
t.set("K1", "=PIVOTBY(A1:A3,B1:B3,C1:C3,LAMBDA(x,SUM(x)))");
assert_eq!(t.get("L1"), Value::from("x"));
assert_eq!(t.num("L2"), 1.0);
assert_eq!(t.num("M2"), 3.0);
assert_eq!(t.num("N4"), 6.0);
// Neither a function nor a lambda.
t.set("P1", "=GROUPBY(A1:A3,C1:C3,TRUE)");
assert_eq!(t.get("P1"), Value::Error(CellError::Value));
}
#[test]
fn lambda_argument_count() {
let mut t = T::new();
@@ -657,6 +788,24 @@ fn names_and_sheets() {
assert_eq!(t.num("A1"), 5.0);
}
#[test]
fn name_referring_to_several_ranges() {
let mut t = T::new();
// As Excel writes it in `<definedName>`: a union with no parentheses around it.
let union = "Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2";
t.wb.names.push(gridcraft_model::DefinedName { name: "u".into(), scope: None, formula: union.into(), comment: String::new(), hidden: false });
for (at, v) in [("A1", "1"), ("A2", "2"), ("C1", "5"), ("C2", "7")] {
t.set(at, v);
}
t.set("E1", "=SUM(u)");
t.set("E2", "=AREAS(u)");
t.set("E3", "=MIN(u)");
t.set("E4", "=INDEX(u,2,1,2)");
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (15.0, 2.0, 1.0, 7.0));
t.set("C2", "70");
assert_eq!(t.num("E1"), 78.0);
}
#[test]
fn legacy_array_formulas_fill_their_range() {
let mut t = T::new();
@@ -684,6 +833,34 @@ fn legacy_array_formulas_fill_their_range() {
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
}
#[test]
fn array_broadcast_result_is_capped() {
// A full column against a full row broadcasts to 1048576 x 16384 (17 billion cells, about
// 400 GB): each operand is within its own cap, so only the product check stops the abort.
let mut t = T::new();
let col = "SEQUENCE(1048576)";
let row = "SEQUENCE(1,16384)";
for (at, formula) in [
("A1", format!("=ROWS({col}*{row})")),
("A2", format!("=ROWS({col}&{row})")),
("A3", format!("=ROWS({col}>{row})")),
("A4", format!("=ROWS(IF({col},{row}))")),
("A5", format!("=ROWS(IF({col}>0,1,{row}))")),
("A6", format!("=ROWS(TEXT({col},{row}))")),
("A7", format!("=ROWS(MAP({col},{row},LAMBDA(a,b,a)))")),
] {
t.set(at, &formula);
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
}
// Broadcasts within the cap are unchanged.
t.set("C1", "=SUM(SEQUENCE(3)*SEQUENCE(1,4))");
assert_eq!(t.num("C1"), 60.0);
t.set("C2", "=SUM(IF(SEQUENCE(2)>1,SEQUENCE(1,3),0))");
assert_eq!(t.num("C2"), 6.0);
t.set("C3", "=COLUMNS(TEXT(SEQUENCE(2),SEQUENCE(1,5)))");
assert_eq!(t.num("C3"), 5.0);
}
#[test]
fn text_function() {
let mut t = T::new();
@@ -702,6 +879,41 @@ fn long_operator_chains() {
assert_eq!(t.num("B2"), 5.0);
}
#[test]
fn flat_chains_as_long_as_excel_allows() {
// Values observed in Excel (#916): a flat chain has no operand limit below the 8,192-character
// formula limit, which `=1+1+…` with 4,096 operands meets exactly. On a 1 MiB stack, as on
// the Windows main thread.
std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(|| {
let chain = |op: &str, item: &str, n: usize| format!("={}", vec![item; n].join(op));
let mut t = T::new();
for r in 1..=1500 {
t.set(&format!("A{r}"), "1");
}
t.set("B1", &chain("+", "1", 401));
t.set("B2", &chain("+", "1", 4096));
t.set("B3", &chain("-", "1", 2000));
t.set("B4", &chain("*", "1", 4096));
t.set("B5", &chain("/", "2", 4096));
t.set("B6", &chain("^", "1", 4096));
t.set("B7", &chain("+", "1%", 2000));
t.set("B8", "=1=1=1");
t.set("B9", &format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+")));
t.set("B10", &chain("&", "1", 2000));
for (at, want) in [("B1", 401.0), ("B2", 4096.0), ("B3", -1998.0), ("B4", 1.0), ("B5", 0.0), ("B6", 1.0), ("B9", 1500.0)] {
assert_eq!(t.num(at), want, "{at}");
}
assert!((t.num("B7") - 20.0).abs() < 1e-9);
assert_eq!(t.get("B8"), Value::Bool(false));
assert_eq!(t.get("B10"), Value::text("1".repeat(2000)));
})
.expect("spawn")
.join()
.expect("long chains must not overflow the stack");
}
#[test]
fn subtotal_skips_hidden() {
let mut t = T::new();
@@ -738,6 +950,40 @@ fn subtotal_109_skips_nested_subtotal() {
assert_eq!(t.num("B2"), 3.0);
}
/// One value stored in the sheet's last cell makes `A:XFD` trim to ~17 billion cells. SUBTOTAL
/// and AGGREGATE used to read every one of them into a vector (an abort on open, #2175); now
/// every argument is charged against `MAX_CELLS` before any cell is read, and `#NUM!` comes back.
#[test]
fn subtotal_and_aggregate_cap_cells_visited() {
let mut t = T::new();
t.set("XFD1048576", "1");
let formulas = [
"=SUBTOTAL(9,A:XFD)",
"=SUBTOTAL(109,1:1048576)",
"=AGGREGATE(9,6,A:XFD)",
"=AGGREGATE(14,7,A:XFD,1)",
// A:J and K:T are about 10.5 million cells each: under the limit alone, over it together,
// so the second area is refused before the first is read.
"=SUBTOTAL(9,A:J,K:T)",
"=AGGREGATE(9,4,(A:J,K:T))",
];
for (i, f) in formulas.iter().enumerate() {
let at = format!("B{}", i + 1);
t.set(&at, f);
assert_eq!(t.get(&at), Value::Error(CellError::Num), "{f}");
}
// Whole columns and rows still clip to the used range when it is small.
let mut t = T::new();
t.set("A1", "1");
t.set("B2", "2");
t.set("C2", "#N/A");
t.set("E5", "=SUBTOTAL(9,A:B)");
t.set("E6", "=AGGREGATE(9,6,1:2)");
t.set("E7", "=AGGREGATE(14,6,A:C,1)");
assert_eq!((t.num("E5"), t.num("E6"), t.num("E7")), (3.0, 3.0, 2.0));
}
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
/// under a filtered table adds only the visible rows).
@@ -1266,3 +1512,272 @@ fn offset_extreme_arguments_return_ref() {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
#[test]
fn long_dependency_chains_against_row_order_calculate() {
const N: u32 = 20_000;
let mut t = T::new();
{
let sh = t.wb.sheet_mut(0).unwrap();
for r in 1..N {
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
}
sh.cells.set(c(&format!("A{N}")), Cell::value(Value::Number(1.0)));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), N as f64);
assert_eq!(t.num("A2"), (N - 1) as f64);
assert!(t.calc.circular.is_empty());
// An edit at the far end recalculates the whole chain.
t.set(&format!("A{N}"), "10");
assert_eq!(t.num("A1"), (N + 9) as f64);
assert!(t.calc.circular.is_empty());
// Hopping between two sheets at every step, through a range read (SUM) as well.
let mut t = T::new();
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
for r in 1..N {
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=Data!A{r}+1"))));
let next = format!("=SUM(Sheet1!A{}:A{})", r + 1, r + 1);
t.wb.sheet_mut(1).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&next)));
}
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{N}")), Cell::value(Value::Number(0.0)));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), (N - 1) as f64);
assert_eq!(t.wb.sheet(1).unwrap().value(c("A1")), Value::Number((N - 2) as f64));
assert!(t.calc.circular.is_empty());
}
/// A loop longer than the on-demand nesting limit is still a circular reference (and still ends).
#[test]
fn long_circular_chain_is_circular() {
const N: u32 = 5_000;
let mut t = T::new();
{
let sh = t.wb.sheet_mut(0).unwrap();
for r in 1..N {
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
}
sh.cells.set(c(&format!("A{N}")), Cell::formula(Formula::new("=A1+1")));
sh.cells.set(c("B1"), Cell::formula(Formula::new("=1+2")));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.get(&format!("A{N}")), Value::Error(CellError::Circ));
assert_eq!(t.num("B1"), 3.0);
assert!(!t.calc.circular.is_empty());
// Each formula is listed once at most.
let mut seen = t.calc.circular.clone();
seen.sort_by_key(|(s, c)| (*s, c.row, c.col));
seen.dedup();
assert_eq!(seen.len(), t.calc.circular.len());
// With iterative calculation the loop settles to numbers instead (no hang, no crash).
t.wb.calc.iterative = true;
t.wb.calc.max_iterations = 3;
t.calc.recalc_all(&mut t.wb);
assert!(t.get("A1").as_f64().is_some());
}
/// A loop closing back into the middle of a chain deeper than the on-demand nesting limit is found
/// where calculating it in one go finds it: at the formula read while it was being calculated.
#[test]
fn deep_loop_is_found_where_it_closes() {
let mut t = T::new();
{
let sh = t.wb.sheet_mut(0).unwrap();
for r in 1..300 {
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
}
sh.cells.set(c("A300"), Cell::formula(Formula::new("=A30+1")));
// Reads the loop through IFERROR, so its value shows where the loop was cut.
sh.cells.set(c("B1"), Cell::formula(Formula::new("=IFERROR(A1,-1)")));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.calc.circular, vec![(0, c("A30"))]);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.num("B1"), -1.0);
}
#[test]
fn large_bounded_references_keep_their_shape() {
// D3:AZ400 is 19,502 cells, past the size where a block used to be cut to the used range
// (which ends at column F here). Sliced that way it no longer lined up with the full-size
// operands next to it. Expected values observed in Excel 365 on the same layout.
let mut t = T::new();
for (at, v) in [
("D1", "1"),
("E1", "2"),
("F1", "3"),
("A3", "1"),
("A4", "2"),
("A5", "1"),
("B3", "2"),
("B4", "3"),
("B5", "4"),
("D3", "10"),
("E3", "20"),
("F3", "30"),
("D4", "40"),
("E4", "50"),
("F4", "60"),
("D5", "70"),
("E5", "80"),
("F5", "90"),
] {
t.set(at, v);
}
let cases = [
("=SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(360.0)),
("=ROWS(D3:AZ400*1)", Value::Number(398.0)),
("=COLUMNS(D3:AZ400*1)", Value::Number(49.0)),
("=SUMPRODUCT(D3:AZ400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(100.0)),
("=SUMPRODUCT(D3:AZ400+0,D3:AZ401)", Value::Error(CellError::Value)),
("=COUNTBLANK(D3:AZ400)", Value::Number(19493.0)),
("=SUMPRODUCT(--(D3:AZ400=\"\"))", Value::Number(19493.0)),
("=SUM(D3:AZ400*B3:B400)", Value::Number(1530.0)),
("=COLUMNS(TRANSPOSE(D3:AZ400))", Value::Number(398.0)),
// Whole columns are still read up to the used range.
("=SUMPRODUCT((A:A=1)*B:B)", Value::Number(6.0)),
];
for (i, (formula, want)) in cases.iter().enumerate() {
let at = format!("C{}", 1001 + i);
t.set(&at, formula);
assert_eq!(&t.get(&at), want, "{formula}");
}
}
#[test]
fn median_large_values_recalculate_dependents() {
let mut t = T::new();
t.set("A1", "1e308");
t.set("A2", "1.6e308");
t.set("B1", "=MEDIAN(A1:A2)");
t.set("B2", "=AGGREGATE(12,0,A1:A2)");
t.set("C1", "=B1/1e308");
t.set("C2", "=B2/1e308");
for cell in ["B1", "B2"] {
assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14);
}
for cell in ["C1", "C2"] {
assert!((t.num(cell) - 1.3).abs() < 1e-14);
}
t.set("A2", "-1e308");
for cell in ["B1", "B2", "C1", "C2"] {
assert_eq!(t.num(cell), 0.0);
}
t.set("A1", "-1.6e308");
for cell in ["B1", "B2"] {
assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14);
}
for cell in ["C1", "C2"] {
assert!((t.num(cell) + 1.3).abs() < 1e-14);
}
}
#[test]
fn cell_filename_names_the_workbook_file() {
// Excel returns `<folder>/[<file>]<sheet>`, and the classic sheet-name formula splits it on
// the `]`; an empty string broke that formula in every German workbook that used it (#1489).
let dir = std::path::Path::new("books");
let mut t = T::new();
t.wb.sheet_mut(0).unwrap().name = "Info".into();
// No file yet: empty, as in a workbook Excel has never saved.
t.set("A1", "1");
t.set("B1", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B1"), Value::from(""));
t.wb.source_path = Some(dir.join("gc_cell_info_de.xlsx"));
t.calc = Calc::new();
let expect = dir.join("[gc_cell_info_de.xlsx]Info").to_string_lossy().into_owned();
t.set("B2", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B2"), Value::from(expect.as_str()));
// The two formulas from the report: the sheet name, and the test that the `]` is there.
t.set("B3", "=MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)");
assert_eq!(t.get("B3"), Value::from("Info"), "the sheet-name formula");
t.set("B4", "=ISNUMBER(FIND(\"]\",CELL(\"filename\",A1)))");
assert_eq!(t.get("B4"), Value::Bool(true));
// The sheet is the one the reference is on, not the active one.
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Second")));
t.set("B6", "=CELL(\"filename\",Second!A1)");
assert_eq!(t.get("B6"), Value::from(dir.join("[gc_cell_info_de.xlsx]Second").to_string_lossy().into_owned().as_str()));
// A workbook that arrived as bytes knows its name but no folder: still splittable.
t.wb.source_path = Some(std::path::PathBuf::from("Book.xlsx"));
t.calc = Calc::new();
t.set("B7", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info"));
}
#[test]
fn n_lifts_over_a_computed_array_but_reads_the_first_cell_of_a_range() {
let mut t = T::new();
t.set("B1", "0");
t.set("B2", "23850");
t.set("D2", "10%");
t.set("D3", "12%");
t.set("F1", "50000");
t.set("H1", "=SUMPRODUCT(D2:D3-D1:D2,F1-B1:B2,N(F1>B1:B2))");
assert!((t.num("H1") - 5523.0).abs() < 1e-9, "{:?}", t.get("H1"));
t.set("H2", "=SUMPRODUCT(N(B1:B2>0))");
assert_eq!(t.num("H2"), 1.0);
// A spill of N(D2:D3) would also show 10% in H3; summing it tells the two apart.
t.set("H3", "=SUM(N(D2:D3))");
assert!((t.num("H3") - 0.1).abs() < 1e-12, "{:?}", t.get("H3"));
}
#[test]
fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
// In Excel a dynamic-array formula anchors a spill range even while its result is a single
// value, so `C1#` is the 1 x 1 range and ROWS/SUM/COUNT of it work. GridCraft gave #REF!
// (and COUNT 0), so every formula counting a filtered list broke the moment the list had
// exactly one entry (#1491). Values and cells follow the reported fixture.
let mut t = T::new();
for i in 1..=5 {
t.set(&format!("A{i}"), &i.to_string());
t.set(&format!("B{i}"), "7");
}
t.set("C1", "=FILTER(A1:A5,A1:A5>4)");
t.set("C3", "=SORT(FILTER(A1:A5,A1:A5=3))");
t.set("C5", "=UNIQUE(B1:B5)");
// Control: the same FILTER matching two rows, which worked before.
t.set("C9", "=FILTER(A1:A5,A1:A5>3)");
t.set("E1", "=ROWS(C1#)");
t.set("E2", "=SUM(C1#)");
t.set("E3", "=ROWS(C3#)");
t.set("E5", "=COUNT(C5#)");
t.set("E9", "=ROWS(C9#)");
assert_eq!(t.get("E1"), Value::from(1.0));
assert_eq!(t.get("E2"), Value::from(5.0));
assert_eq!(t.get("E3"), Value::from(1.0));
assert_eq!(t.get("E5"), Value::from(1.0));
assert_eq!(t.get("E9"), Value::from(2.0), "a spilling array is unchanged");
// The anchors still show their first value, as they did.
assert_eq!(t.get("C1"), Value::from(5.0));
assert_eq!(t.get("C5"), Value::from(7.0));
// A formula whose result is a scalar never becomes an anchor: `#` on it is still #REF!.
t.set("D1", "=1+1");
t.set("E11", "=ROWS(D1#)");
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
}
#[test]
fn text_before_after_empty_instance_defaults_to_one() {
let mut t = T::new();
t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)");
assert_eq!(t.get("A1"), Value::Text("Little ".into()));
t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")");
assert_eq!(t.get("A2"), Value::Text("b".into()));
t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")");
assert_eq!(t.get("A3"), Value::Text("nf".into()));
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
}
+5
View File
@@ -123,6 +123,11 @@ impl Measure for ApproxMeasure {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChartData {
pub categories: Vec<String>,
/// Outer levels of a multi-level category axis (a category range two columns wide beside a
/// column of values: year beside month), nearest the labels first. One entry per category,
/// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`.
#[serde(default)]
pub category_levels: Vec<Vec<String>>,
pub series: Vec<SeriesData>,
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
#[serde(default)]
+121 -20
View File
@@ -3,7 +3,7 @@
use std::collections::HashSet;
use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind};
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
@@ -192,6 +192,7 @@ fn sanitize(d: &ChartData) -> ChartData {
ChartData {
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(),
series: d
.series
.iter()
@@ -391,10 +392,20 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos
// ---------------------------------------------------------------- axes frame
enum CatAxis<'a> {
Labels(&'a [String]),
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
/// one entry per category, non-empty where a group starts).
Labels(&'a [String], &'a [Vec<String>]),
Numeric(Scale, &'a Fmt),
}
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
/// non-empty entry (and at the first category).
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
starts.push(n);
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
struct ValAxis<'a> {
scale: Scale,
fmt: &'a Fmt,
@@ -420,7 +431,11 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
_ => 4.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 };
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) => 0.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
if !plot.ok() {
return plot;
}
@@ -441,7 +456,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Labels(l) => {
CatAxis::Labels(l, outer) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
@@ -450,6 +465,22 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
}
// Outer levels, one row each below the labels: a group's name centred under its
// categories, and a separator at each group boundary down through the rows.
for (i, level) in outer.iter().enumerate() {
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
for (first, end) in level_groups(level, n) {
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);
}
if first > 0 {
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
CatAxis::Numeric(s, f) => {
let ticks = s.ticks();
@@ -469,13 +500,17 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
}
plot
} else {
let labels: Vec<String> = match cat {
CatAxis::Labels(l) => l.to_vec(),
CatAxis::Numeric(..) => vec![],
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
CatAxis::Labels(l, o) => (l.to_vec(), o),
CatAxis::Numeric(..) => (vec![], &[]),
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
// Outer levels stand in columns left of the labels, each as wide as its longest name.
let level_w: Vec<f32> =
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
let levels_w: f32 = level_w.iter().sum();
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 };
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
@@ -501,6 +536,24 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
}
// Outer levels: a group's name centred beside its categories, separators at the
// boundaries running right to the axis.
let mut x1 = plot.x - lw;
for (level, w) in outer.iter().zip(&level_w) {
let x0 = x1 - w;
for (first, end) in level_groups(level, n) {
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, w - 4.0);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);
}
if first > 0 {
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
}
}
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
x1 = x0;
}
plot
}
}
@@ -658,6 +711,13 @@ fn forecast(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
}
/// Most empty category slots a forward forecast may add after `ncat` categories: four times the
/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every
/// column to nothing.
fn max_forward_slots(ncat: usize) -> f64 {
(ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64
}
/// Longest forward forecast among `s`'s regression trendlines.
fn max_forward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
@@ -748,6 +808,7 @@ fn draw_trendlines(
frac: &dyn Fn(f64, f64) -> (f64, f64),
px: &dyn Fn((f64, f64)) -> [f32; 2],
labels: &mut Vec<String>,
window: (f64, f64),
) {
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
@@ -756,7 +817,8 @@ fn draw_trendlines(
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
_ => {
let Some(fit) = fit_trendline(spec, pts) else { continue };
let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward));
// Sample only the part of the forecast that is on the plot.
let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1));
if spec.display_equation {
labels.push(fit.equation());
}
@@ -936,9 +998,12 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
.map(|(_, s)| max_forward(s))
.fold(0.0, f64::max);
let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat));
let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat));
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines);
// Outer levels padded to the slots (forecast slots stay in the last group).
let outer: Vec<Vec<String>> =
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1102,7 +1167,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
}
};
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
}
draw_trend_labels(ctx, plot, &trend_labels);
}
@@ -1196,7 +1261,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max));
}
draw_trend_labels(ctx, plot, &trend_labels);
}
@@ -1236,7 +1301,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
let ys = nice_scale(0.0, maxc, true);
let fc = Fmt::new("General");
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1288,7 +1353,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
let ys = nice_scale(lo, hi, true);
let f = Fmt::new(&s.number_format);
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1561,12 +1626,16 @@ fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
// ---------------------------------------------------------------- box & whisker
fn quantile(sorted: &[f64], q: f64) -> f64 {
fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
let pos = q * (n - 1) as f64;
// Clamp exclusive ranks for short samples so one or two points remain drawable.
let pos = match method {
QuartileMethod::Inclusive => q * (n - 1) as f64,
QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64),
};
let i = pos.floor() as usize;
let frac = pos - i as f64;
let a = sorted.get(i).copied().unwrap_or(0.0);
@@ -1579,7 +1648,7 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1593,7 +1662,8 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
continue;
}
v.sort_by(f64::total_cmp);
let (q1, med, q3) = (quantile(&v, 0.25), quantile(&v, 0.5), quantile(&v, 0.75));
let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default();
let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method));
let iqr = q3 - q1;
let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1);
@@ -1627,7 +1697,7 @@ fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1769,3 +1839,34 @@ mod smooth_tests {
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
}
#[cfg(test)]
mod quartile_tests {
use super::*;
#[test]
fn box_whisker_default_quartiles_are_exclusive() {
let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75);
assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5);
assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25);
}
#[test]
fn quartile_methods_and_short_samples() {
for (values, exclusive, inclusive) in [
(vec![1., 2., 3.], (1., 3.), (1.5, 2.5)),
(vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)),
(vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)),
(vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)),
(vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)),
(vec![1., 2.], (1., 2.), (1.25, 1.75)),
(vec![1.], (1., 1.), (1., 1.)),
(vec![], (0., 0.), (0., 0.)),
] {
for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] {
assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected);
}
}
}
}
+42 -10
View File
@@ -1,7 +1,7 @@
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
use gridcraft_calc::evaluate;
use gridcraft_core::{CellRef, RangeRef, Value, number_to_text};
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
use gridcraft_numfmt::{NumberFormat, format_value};
@@ -41,7 +41,7 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
let (sh, addr) = match f.rfind('!') {
Some(i) => {
let name = f.get(..i)?.trim();
let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string());
let name = gridcraft_core::unquote_sheet(name);
(wb.sheet_index(&name)?, f.get(i + 1..)?)
}
None => (sheet, f),
@@ -133,12 +133,16 @@ fn value_text(v: &Value) -> String {
}
}
/// Category labels, formatted with each source cell's number format (dates, currency…).
fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
/// Category labels, formatted with each source cell's number format (dates, currency…), and
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
/// has one entry per category, non-empty where a group starts, as the cells are.
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
let vals = eval_list(wb, sheet, formula);
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
vals.iter()
let texts: Vec<String> = vals
.iter()
.enumerate()
.map(|(i, v)| {
if let (Some((s, r)), Value::Number(_)) = (src, v) {
@@ -153,16 +157,42 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
}
value_text(v)
})
.collect()
.collect();
let Some((_, r)) = src else { return (texts, vec![]) };
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
if texts.len() != w.saturating_mul(h) {
return (texts, vec![]); // truncated or not a plain block: a flat list
}
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
let outer = levels.outer as usize;
if levels.by_columns {
(column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect())
} else {
(row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())
}
}
/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is.
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
/// that sheet), the way the evaluator looks names up.
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
match t.split_once('!') {
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
None => wb.name(t, sheet).is_some(),
}
}
/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated,
/// a quoted or other bare literal is used as is.
fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
let t = name.trim();
if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) {
return q.replace("\"\"", "\"");
}
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') {
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) {
let vals = eval_list(wb, sheet, t);
let parts: Vec<String> = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect();
return parts.join(" ");
@@ -197,6 +227,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
let mut series = Vec::new();
let mut categories: Option<Vec<String>> = None;
let mut category_levels = Vec::new();
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
let values = nums(wb, sheet, &s.values);
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
@@ -211,9 +242,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
if categories.is_none()
&& let Some(f) = &s.categories
{
let c = category_texts(wb, sheet, f);
let (c, outer) = category_table(wb, sheet, f, &s.values);
if !c.is_empty() {
categories = Some(c);
category_levels = outer;
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
@@ -242,5 +274,5 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
ChartData { categories, series, palette }
ChartData { categories, category_levels, series, palette }
}
+171 -13
View File
@@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
b.secondary = kind == ChartKind::Combo;
ChartData {
category_levels: vec![],
palette: vec![],
categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![
@@ -227,6 +228,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
},
@@ -238,6 +240,7 @@ fn resolve_from_workbook() {
color: Some(Color::rgb(1, 2, 3)),
secondary: true,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
},
@@ -249,6 +252,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
},
@@ -287,6 +291,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
},
@@ -298,6 +303,7 @@ fn resolve_from_workbook() {
color: None,
secondary: true,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
},
@@ -314,6 +320,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
@@ -331,6 +338,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
@@ -363,7 +371,8 @@ fn scatter_with_text_x_values_numbers_the_points() {
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x, None, "{kind:?}");
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
let empty =
render(&c, &ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
// Numeric X values are kept.
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
@@ -456,8 +465,12 @@ fn pie_starts_at_twelve_clockwise() {
#[test]
fn negative_values_put_axis_below_zero() {
let c = chart(ChartKind::ColumnClustered);
let d =
ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)],
};
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let t = texts(&prims);
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
@@ -469,11 +482,13 @@ fn edge_cases_never_panic() {
let m = ApproxMeasure;
let empty = ChartData::default();
let zeros = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec!["a".into(), "b".into()],
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
};
let weird = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![String::new(); 3],
series: vec![SeriesData {
@@ -505,7 +520,12 @@ fn thousands_of_points_are_decimated() {
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
let mut c = chart(ChartKind::Line);
c.data_labels = true;
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)],
};
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
let max_line = prims
.iter()
@@ -530,7 +550,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = smooth;
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats.clone(), series: vec![s] };
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
.iter()
.filter_map(|p| match p {
@@ -547,7 +567,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
// Rendering either way must stay finite and inside the canvas.
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats, series: vec![s] };
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
}
@@ -557,7 +577,7 @@ fn smooth_line_splits_at_gaps() {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
s.values[1] = None; // gap between two data points
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
// Two separate runs (before/after the gap) → at least two line primitives.
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
@@ -634,7 +654,12 @@ fn fuzz_random_data_and_sizes() {
})
.collect();
let ncat = (r.next() % 50) as usize;
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(),
series,
};
let mut c = chart(kind);
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
c.data_labels = r.next().is_multiple_of(2);
@@ -809,7 +834,8 @@ fn line_chart_draws_trendline_forecast_and_labels() {
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
s.trendlines =
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let d =
ChartData { category_levels: vec![], palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let mut c = chart(ChartKind::Line);
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
let prims = render(&c, &d, 600.0, 400.0, &m);
@@ -833,7 +859,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
assert!(lines_of(&pie, tc).is_empty());
// One point: nothing to fit, nothing drawn.
s.values.truncate(1);
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
let one = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into()], series: vec![s] };
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
}
@@ -853,7 +879,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
];
let d = ChartData { palette: vec![], categories: vec![], series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![s] };
let mut c = chart(ChartKind::Scatter);
c.legend = LegendPos::None;
let prims = render(&c, &d, 600.0, 400.0, &m);
@@ -868,6 +894,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
#[test]
fn treemap_and_histogram_shapes() {
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
@@ -889,7 +916,12 @@ fn treemap_and_histogram_shapes() {
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)],
};
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
assert_eq!(bins, 10);
@@ -898,7 +930,12 @@ fn treemap_and_histogram_shapes() {
#[test]
fn histogram_boundary_values_match_interval_labels() {
let heights = |vals: &[f64]| -> Vec<f32> {
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)],
};
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect()
};
@@ -967,6 +1004,7 @@ fn box_and_stock_charts_honour_the_gridlines_flag() {
#[test]
fn funnel_value_labels_follow_the_data_labels_flag() {
let data = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into()],
series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)],
@@ -1001,6 +1039,7 @@ fn pie_points_follow_workbook_theme() {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
@@ -1057,3 +1096,122 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
}
}
#[test]
fn two_level_categories_resolve_and_draw() {
let years = ["2023", "", "", "2024", "", ""];
let months = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "Sales".into();
for (i, (y, m)) in years.iter().zip(months).enumerate() {
// Year beside month beside the values, points down the rows…
let r = i as u32 + 1;
if !y.is_empty() {
s.set_value(CellRef::new(r, 0), Value::from(*y));
s.set_value(CellRef::new(10, i as u32), Value::from(*y));
}
s.set_value(CellRef::new(r, 1), Value::from(m));
s.set_value(CellRef::new(r, 2), Value::Number(10.0 * (i as f64 + 1.0)));
// …and the same table in rows, points along the columns.
s.set_value(CellRef::new(11, i as u32), Value::from(m));
s.set_value(CellRef::new(12, i as u32), Value::Number(10.0 * (i as f64 + 1.0)));
}
}
let mut c = chart(ChartKind::Line);
c.series = vec![Series { categories: Some("Sales!$A$2:$B$7".into()), values: "Sales!$C$2:$C$7".into(), ..Default::default() }];
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, months);
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
let texts = |prims: &[Prim]| -> Vec<(String, f32, f32)> {
prims.iter().filter_map(|p| if let Prim::Text { text, x, y, .. } = p { Some((text.clone(), *x, *y)) } else { None }).collect()
};
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 600.0, 300.0);
let t = texts(&prims);
let at = |t: &[(String, f32, f32)], s: &str| {
t.iter().find(|(x, _, _)| x == s).map(|(_, x, y)| (*x, *y)).unwrap_or_else(|| panic!("no {s:?} in {t:?}"))
};
// Six evenly spaced month labels across the plot, not twelve slots for a two-column range.
let xs: Vec<f32> = months.iter().map(|m| at(&t, m).0).collect();
let step = xs[1] - xs[0];
assert!(step > 40.0 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "{xs:?}");
// The years sit in a row below the months, each under its own group.
let (x23, y23) = at(&t, "2023");
let (x24, y24) = at(&t, "2024");
assert!(y23 > at(&t, "Oct").1 && (y23 - y24).abs() < 0.01, "{t:?}");
assert!(x23 < at(&t, "Dec").0 && x24 > at(&t, "Dec").0 && x24 < at(&t, "Mar").0, "{t:?}");
// Bars: the levels stand in a column left of the labels.
c.kind = ChartKind::BarClustered;
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 600.0, 300.0);
let t = texts(&prims);
let (x23, y23) = at(&t, "2023");
let (x_oct, y_oct) = at(&t, "Oct");
// Categories run up the axis: the group's name sits left of its labels, between Oct and Dec.
assert!(x23 < x_oct && y23 < y_oct && y23 > at(&t, "Dec").1, "{t:?}");
let (x24, y24) = at(&t, "2024");
assert!((x23 - x24).abs() < 0.01 && y24 < y23, "same column, 2024 above: {t:?}");
// A row of values: the levels are rows.
c.series[0].categories = Some("Sales!$A$11:$F$12".into());
c.series[0].values = "Sales!$A$13:$F$13".into();
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, months);
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
// One column of categories: a plain axis, as before.
c.series[0].categories = Some("Sales!$B$2:$B$7".into());
c.series[0].values = "Sales!$C$2:$C$7".into();
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, months);
assert!(d.category_levels.is_empty());
}
#[test]
fn a_huge_trendline_forecast_does_not_collapse_the_columns() {
let m = ApproxMeasure;
let tc: Rgba = [1, 2, 3, 255];
let col: Rgba = [10, 20, 30, 255];
let mut s = series("Sales", &[1.0, 3.0, 2.0, 5.0, 4.0], col, ChartKind::ColumnClustered);
s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1e6, backward: 1e6, ..Trendline::default() }, color: tc }];
let d = ChartData { palette: vec![], category_levels: vec![], categories: ["a", "b", "c", "d", "e"].map(String::from).to_vec(), series: vec![s] };
let mut c = chart(ChartKind::ColumnClustered);
c.legend = LegendPos::None;
let prims = render(&c, &d, 600.0, 400.0, &m);
assert_sane(&prims, 600.0, 400.0);
let widths: Vec<f32> =
prims.iter().filter_map(|p| if let Prim::Rect { w, fill: Some(f), .. } = p { (*f == col).then_some(*w) } else { None }).collect();
assert_eq!(widths.len(), 5);
assert!(widths.iter().all(|w| *w > 4.0), "columns stay visible: {widths:?}");
let trend = lines_of(&prims, tc);
assert_eq!(trend.len(), 1);
assert!(trend[0].len() >= 20, "the trendline is sampled over the plot, not the forecast: {}", trend[0].len());
}
#[test]
fn box_whisker_draws_each_series_quartile_method() {
use gridcraft_model::QuartileMethod;
let mut c = chart(ChartKind::BoxWhisker);
c.legend = LegendPos::None;
c.series = vec![Series::default(), Series { quartiles: QuartileMethod::Inclusive, ..Default::default() }];
let color = [12, 34, 56, 255];
let d = ChartData {
series: vec![series("A", &[1., 2., 3., 4., 5., 6.], color, c.kind), series("B", &[1., 2., 3., 4., 5., 6.], color, c.kind)],
..Default::default()
};
let prims = render(&c, &d, 640., 400., &ApproxMeasure);
let heights: Vec<f32> = prims
.iter()
.filter_map(|p| match p {
Prim::Rect { h, fill, .. } if *fill == Some(color) => Some(*h),
_ => None,
})
.collect();
assert_eq!(heights.len(), 2);
// The boxes span 3.5 and 2.5 data units on the same value axis.
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
+167 -2
View File
@@ -210,11 +210,18 @@ impl RangeRef {
if let (Some(x), Some(y)) = (CellRef::parse(a), CellRef::parse(b)) {
return Some(RangeRef::new(x, y));
}
let strip = |t: &str| t.trim_start_matches('$').to_string();
let strip = |t: &str| t.strip_prefix('$').unwrap_or(t).to_string();
if let (Some(x), Some(y)) = (letters_to_col(&strip(a)), letters_to_col(&strip(b))) {
return Some(RangeRef::cols(x.min(y), x.max(y)));
}
let (pa, pb) = (strip(a).parse::<u32>().ok()?, strip(b).parse::<u32>().ok()?);
let row_num = |t: &str| {
let t = t.strip_prefix('$').unwrap_or(t);
if t.is_empty() || !t.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
t.parse::<u32>().ok()
};
let (pa, pb) = (row_num(a)?, row_num(b)?);
if pa == 0 || pb == 0 || pa > MAX_ROWS || pb > MAX_ROWS {
return None;
}
@@ -230,10 +237,119 @@ impl fmt::Display for RangeRef {
}
}
/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1`
/// in any case.
fn is_r1c1_token(name: &str) -> bool {
let mut chars = name.chars().peekable();
let mut letters = 0;
for letter in ['r', 'c'] {
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
chars.next();
letters += 1;
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
chars.next();
}
}
}
letters > 0 && chars.next().is_none()
}
/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or
/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell within
/// the sheet or as an R1C1 reference, or is a boolean.
pub fn sheet_needs_quotes(name: &str) -> bool {
let Some(first) = name.chars().next() else {
return true;
};
if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) {
return true;
}
CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE")
}
/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it.
pub fn quote_sheet(name: &str) -> String {
if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() }
}
/// The inverse of [`quote_sheet`]: takes the quotes off a sheet name and reads `''` as `'`.
/// Text that isn't quoted is returned as it is.
pub fn unquote_sheet(text: &str) -> String {
match text.trim().strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
Some(inner) => inner.replace("''", "'"),
None => text.trim().to_string(),
}
}
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). Splits at the last `!`, so a quoted name may
/// hold one (`'a!b'!A1`).
pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
match reference.rsplit_once('!') {
Some((sheet, body)) => (Some(unquote_sheet(sheet)), body),
None => (None, reference),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_parse_rejects_signed_rows() {
assert!(RangeRef::parse("+1:+3").is_none());
assert!(RangeRef::parse("-1:3").is_none());
assert!(RangeRef::parse("$$1:3").is_none());
assert!(RangeRef::parse(":3").is_none());
assert!(RangeRef::parse("$$A:$$C").is_none());
assert!(RangeRef::parse("$A:$C").is_some());
let r = RangeRef::parse("$1:$3").unwrap();
assert_eq!((r.start.row, r.end.row), (0, 2));
}
#[test]
fn sheet_quoting_follows_excel() {
for quoted in [
"A1",
"XFD1",
"AB1048576",
"R1",
"C1",
"RC1",
"R",
"C",
"RC",
"R1C",
"R1C1",
"r2c3",
"CR1",
".x",
"TRUE",
"false",
"1abc",
"My Sheet",
"Bob's",
"a-b",
"",
] {
assert!(sheet_needs_quotes(quoted), "{quoted:?} should be quoted");
}
for bare in ["Sheet1", "XFE1", "AB1048577", "Ärger", "日本", "Σ", "Sheet.1", "_x", "A1A", "Data", "RR", "CRX"] {
assert!(!sheet_needs_quotes(bare), "{bare:?} should stay bare");
}
}
#[test]
fn quote_unquote_and_split_sheet() {
assert_eq!(quote_sheet("Bob's"), "'Bob''s'");
assert_eq!(quote_sheet("Data"), "Data");
assert_eq!(unquote_sheet("'Bob''s'"), "Bob's");
assert_eq!(unquote_sheet("Data"), "Data");
assert_eq!(unquote_sheet("'"), "'");
assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1"));
assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2"));
assert_eq!(split_sheet("A1"), (None, "A1"));
}
#[test]
fn letters_roundtrip() {
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
@@ -293,3 +409,52 @@ mod tests {
assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3));
}
}
/// The shape of a chart's category range beside its values: a plain list, or a multi-level
/// axis (Excel: a category range two or more columns wide beside a column of values is a
/// two-level axis, year beside month; one two or more rows tall above a row of values likewise).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CategoryLevels {
/// The levels are columns and the points run down the rows (else the levels are rows and the
/// points run along the columns).
pub by_columns: bool,
/// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a
/// two-level axis. At most 3 are kept.
pub outer: u32,
}
/// How `categories` lays its labels out beside `values` (`None` when the values aren't a
/// reference: the categories' own shape decides). `None` for a plain list of labels.
pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option<CategoryLevels> {
if categories.is_full_rows() || categories.is_full_cols() {
return None;
}
let (w, h) = (categories.width(), categories.height());
let vertical = match values {
Some(v) => v.height() >= v.width(),
None => h >= w,
};
let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) };
(outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) })
}
#[cfg(test)]
mod category_level_tests {
use super::*;
#[test]
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
let r = |s: &str| RangeRef::parse(s).unwrap();
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
// A row of values: levels are rows.
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
// Literal values: the categories' own shape decides.
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Shared legacy indexed colours used by XLSX and number-format rendering.
/// The 64-entry legacy indexed colour palette, stored as `RRGGBB`.
pub const INDEXED: [u32; 64] = [
0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00,
0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF,
0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF,
0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696,
0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333,
];
/// Resolve an Excel number-format color index (`Color1` through `Color56`).
pub fn indexed_color(index: u8) -> Option<[u8; 3]> {
if !(1..=56).contains(&index) {
return None;
}
let slot = index.checked_add(7)?;
let rgb = *INDEXED.get(slot as usize)?;
Some([(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8])
}
+111 -24
View File
@@ -28,15 +28,22 @@ pub struct DateTime {
pub weekday: u32,
}
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
///
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = m as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
.and_then(|date| {
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
}
}
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
/// [`days_from_civil`]).
///
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
/// 1970-01-01 is `num_days_from_ce` 719_163.
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
use chrono::Datelike;
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
}
// Unreachable: `rest + 719_163` is always a valid CE day.
None => (1970, 1, 1),
}
}
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
const UNIX_1904_01_01: i64 = -24107;
/// Whether `y` is a Gregorian leap year, for every `i32` year.
pub fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
let y = i64::from(y).rem_euclid(400) as i32;
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
}
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
/// `i32` year.
pub fn days_in_month(y: i32, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
// The length of a month is the day before the first of the next month. Month lengths repeat
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
if !(1..=12).contains(&m) {
return 0;
}
let y = i64::from(y).rem_euclid(400) as i32;
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
_ => 0,
}
}
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
assert!(is_leap(400_000)); // divisible by 400
assert!(!is_leap(300_100)); // century not divisible by 400
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
assert_eq!(days_in_month(400_000, 2), 29);
assert_eq!(days_in_month(300_100, 2), 28);
assert_eq!(days_in_month(400_000, 1), 31);
assert_eq!(days_in_month(400_000, 4), 30);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2023, 0), 0);
assert_eq!(days_in_month(2023, 13), 0);
// December at i32::MAX does not overflow and still reports 31 days.
assert_eq!(days_in_month(i32::MAX, 12), 31);
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
}
#[test]
fn civil_conversions_exact_beyond_chrono_range() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
// the epoch; values round-trip through the proleptic Gregorian converters.
for (y, m, d, z) in [
(262_144i32, 1u32, 1u32, 95_026_602i64),
(300_000, 1, 1, 108_853_222),
(400_000, 2, 29, 145_377_531),
(-262_145, 3, 1, -96_465_964),
(-300_000, 6, 15, -110_292_112),
] {
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
}
// The extremes must not overflow or panic (i128 intermediates).
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
}
#[test]
fn serials_unchanged_through_cycle_conversion() {
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(days_in_month_in(s, 1900, 2), 29);
let s4 = DateSystem::D1904;
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
}
#[test]
fn serial_from_unix_time() {
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
+5 -1
View File
@@ -4,10 +4,14 @@
#![forbid(unsafe_code)]
pub mod addr;
pub mod color;
pub mod date;
pub mod parse;
pub mod value;
pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
pub use addr::{
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
split_sheet, unquote_sheet,
};
pub use date::DateSystem;
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
+111 -12
View File
@@ -49,14 +49,34 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
parse_numeric(s.trim(), sys).map(|(n, _)| n)
}
/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs,
/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and
/// fractions kept as text are not flagged, as in Excel.
pub fn number_stored_as_text(s: &str) -> Option<f64> {
let t = s.trim();
match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n),
None => parse_plain(t).map(|(n, _)| n),
}
}
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
let mut t = t;
let mut neg = false;
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
t = inner.trim();
neg = !neg;
}
let (n, f) = parse_unparenthesised(t, sys)?;
Some((if neg { -n } else { n }, f))
}
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
if t.is_empty() {
return None;
}
// Parenthesised negatives: (100) = -100.
if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f));
}
if let Some(r) = parse_plain(t) {
return Some(r);
}
@@ -146,18 +166,36 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
Some((n, fmt))
}
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
/// finite number (#2163).
fn plain_number(s: &str, frac: bool) -> Option<f64> {
let (int, dec) = match s.split_once('.') {
Some((i, d)) if frac => (i, Some(d)),
_ => (s, None),
};
let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit());
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
return None;
}
s.parse().ok().filter(|x: &f64| x.is_finite())
}
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
fn parse_fraction(t: &str) -> Option<f64> {
let (whole, frac) = t.split_once(' ')?;
let neg = whole.starts_with('-');
let w: f64 = whole.trim_start_matches('-').parse().ok()?;
let w = plain_number(whole.trim_start_matches('-'), false)?;
let (a, b) = frac.trim().split_once('/')?;
let a: f64 = a.parse().ok()?;
let b: f64 = b.parse().ok()?;
let a = plain_number(a, false)?;
let b = plain_number(b, false)?;
if b == 0.0 || !w.is_finite() {
return None;
}
let v = w + a / b;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
@@ -184,12 +222,12 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
if parts.len() == 1 && ampm.is_none() {
return None;
}
if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) {
if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) {
return None;
}
let mut h: f64 = parts.first()?.parse().ok()?;
let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?;
let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?;
let mut h = plain_number(parts.first()?, false)?;
let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?;
let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?;
if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 {
return None;
}
@@ -211,7 +249,13 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
let t = t.trim().trim_end_matches(',');
let num = |s: &str| s.trim().parse::<i64>().ok();
let num = |s: &str| {
let s = s.trim();
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<i64>().ok()
};
// ISO yyyy-mm-dd or yyyy/mm/dd
let seps: &[char] = &['/', '-', '.'];
let parts: Vec<&str> = t.split(seps).collect();
@@ -326,6 +370,32 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
/// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
#[test]
fn overlong_digits_stay_text() {
let big = "9".repeat(400);
for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
assert_eq!(p(s).value, Value::text(s), "{s}");
}
}
#[test]
fn signed_date_parts_stay_text() {
for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] {
assert_eq!(p(s).value, Value::text(s), "{s}");
}
assert_eq!(p("3/4/2026").value, Value::Number(46085.0));
}
#[test]
fn month_name_short_year() {
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
@@ -352,6 +422,24 @@ mod tests {
assert_eq!(p(".5").value, Value::Number(0.5));
}
#[test]
fn deeply_nested_parentheses_do_not_overflow() {
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
let depth = 100_000;
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
assert_eq!(parse_number_text(&s), Some(1.0));
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
assert_eq!(parse_number_text(&odd), Some(-1.0));
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
assert_eq!(parse_number_text(&unbalanced), None);
assert_eq!(p(&s).value, Value::Number(1.0));
// Existing results are unchanged.
assert_eq!(parse_number_text("(100)"), Some(-100.0));
assert_eq!(parse_number_text("((100))"), Some(100.0));
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
assert_eq!(parse_number_text("()"), None);
}
#[test]
fn texts_and_specials() {
assert_eq!(p("hello").value, Value::text("hello"));
@@ -398,4 +486,15 @@ mod tests {
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
}
#[test]
fn numbers_stored_as_text() {
for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] {
assert_eq!(number_stored_as_text(text), Some(n), "{text}");
}
// Dates, times, fractions, words and blanks are not flagged.
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] {
assert_eq!(number_stored_as_text(text), None, "{text}");
}
}
}
+11
View File
@@ -359,6 +359,10 @@ pub fn number_to_text(n: f64) -> String {
if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 {
return sci_text(n, 15);
}
// A value just below 1e15 can round up to 1e15 at 15 significant digits.
if format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {
return sci_text(n, 15);
}
// 15 significant digits, trailing zeros trimmed.
let mag = a.log10().floor() as i32;
let decimals = (14 - mag).clamp(0, 30) as usize;
@@ -402,6 +406,13 @@ mod tests {
assert_eq!(number_to_text(1.0 / 3.0), "0.333333333333333");
}
#[test]
fn number_text_rounds_up_to_scientific_boundary() {
assert_eq!(number_to_text(999999999999999.9), "1E+15");
assert_eq!(number_to_text(-999999999999999.9), "-1E+15");
assert_eq!(number_to_text(999999999999999.0), "999999999999999");
}
#[test]
fn coercion() {
assert_eq!(Value::from("12").to_number(), Ok(12.0));
@@ -0,0 +1,50 @@
//! Times the fill-handle double-click scan against a densely-filled neighbour column: the old
//! row-by-row `value_ref` loop from `grid.rs` vs. `Sheet::last_contiguous_nonempty`.
//! `cargo run --release -p gridcraft-engine --example fill_handle_perf -- [rows]`
#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it
use std::time::Instant;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use serde_json::json;
fn main() {
let rows: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000);
let mut s = Session::new();
s.new_workbook();
let t = Instant::now();
let chunk = 10_000u32;
let mut r = 0u32;
while r < rows {
let n = chunk.min(rows - r);
let vals: Vec<serde_json::Value> = (0..n).map(|i| json!([(r + i) as f64])).collect();
s.execute("range.setValues", json!({"range": format!("A{}", r + 1), "values": vals})).expect("set");
r += n;
}
println!("fill A1:A{rows}: {:?}", t.elapsed());
let d = s.doc().expect("doc");
let sh = d.wb.active().expect("sheet");
let neighbour = 0u32; // column A
let cap = (rows - 1).min(999_999);
let t = Instant::now();
let mut last = 0u32;
let mut iters = 0u64;
while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap
{
last += 1;
iters += 1;
}
let old = t.elapsed();
println!("old row-by-row scan: {iters} iterations, {old:?} ({:.1} ns/iter)", old.as_nanos() as f64 / iters.max(1) as f64);
let t = Instant::now();
let new_last = sh.last_contiguous_nonempty(neighbour, 1, cap);
let new_t = t.elapsed();
println!("new last_contiguous_nonempty: last={new_last}, {new_t:?} ({:.2}x faster)", old.as_secs_f64() / new_t.as_secs_f64().max(1e-12));
assert_eq!(last, new_last, "old and new scans must agree");
}
+1
View File
@@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+194 -25
View File
@@ -34,7 +34,43 @@ struct RuleStats {
counts: HashMap<String, usize>,
}
fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usize>) {
/// Which statistics a rule reads. Scanning the rule's whole range for them costs O(range), so
/// rules that read none (cell value, formula, text, blanks, errors, dates) never scan.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum StatsNeed {
/// Sorted numbers, their mean and deviation (top/bottom N, above average, scales, bars, icons).
Numbers,
/// How often each displayed text occurs (duplicate / unique values).
Counts,
}
fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
match rule {
CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
Some(StatsNeed::Numbers)
}
_ => None,
}
}
impl RuleStats {
fn build(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> RuleStats {
let (sorted, counts) = numbers_in(sh, ranges, need);
let n = sorted.len().max(1) as f64;
let avg = sorted.iter().sum::<f64>() / n;
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
RuleStats { sorted, avg, std, counts }
}
}
#[cfg(test)]
thread_local! {
/// Cells `numbers_in` visited on this thread (tests count the work a lookup does).
static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
let mut nums = Vec::new();
let mut counts = HashMap::new();
for r in ranges {
@@ -43,12 +79,20 @@ fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usi
None => continue,
};
for c in r.iter().take(2_000_000) {
#[cfg(test)]
SCANNED.with(|n| n.set(n.get() + 1));
let v = sh.value(c);
if let Value::Number(n) = v {
nums.push(n);
}
if !v.is_empty() {
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
match need {
StatsNeed::Numbers => {
if let Value::Number(n) = v {
nums.push(n);
}
}
StatsNeed::Counts => {
if !v.is_empty() {
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
}
}
}
}
}
@@ -109,19 +153,13 @@ impl CfCache {
sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect();
rules.sort_by_key(|(_, cf)| cf.priority);
let v = sh.value(c);
let no_stats = RuleStats::default();
for (idx, cf) in rules {
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
let st = self
.stats
.entry(idx)
.or_insert_with(|| {
let (sorted, counts) = numbers_in(sh, &cf.ranges);
let n = sorted.len().max(1) as f64;
let avg = sorted.iter().sum::<f64>() / n;
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
RuleStats { sorted, avg, std, counts }
})
.clone();
let st: &RuleStats = match stats_need(&cf.rule) {
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
None => &no_stats,
};
let text = v.display();
let matched_style: Option<&Style> = match &cf.rule {
CfRule::CellIs { op, a, b, style } => {
@@ -147,10 +185,18 @@ impl CfCache {
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
}
CfRule::ContainsText { text: t, style } => text.to_lowercase().contains(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::NotContainsText { text: t, style } => (!text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()),
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
// As in Excel, an error value never contains, begins or ends with anything (its rule
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
CfRule::ContainsText { text: t, style } => {
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::NotContainsText { text: t, style } => {
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::BeginsWith { text: t, style } => {
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
}
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
@@ -218,7 +264,7 @@ impl CfCache {
if let Some(n) = v.as_f64() {
let pts: Vec<(f64, [u8; 3])> = stops
.iter()
.map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
.map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
.collect();
let rgb = match pts.as_slice() {
[(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }),
@@ -240,8 +286,8 @@ impl CfCache {
}
CfRule::DataBar { min, max, color, gradient, show_value } => {
if let Some(n) = v.as_f64() {
let lo = threshold(wb, sheet, c, &st, min);
let hi = threshold(wb, sheet, c, &st, max);
let lo = threshold(wb, sheet, c, st, min);
let hi = threshold(wb, sheet, c, st, max);
let lo = if lo > 0.0 { 0.0 } else { lo };
let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 };
if out.bar.is_none() {
@@ -270,7 +316,7 @@ impl CfCache {
defaults.get(1..).unwrap_or_default()
}
};
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, st, t)).collect();
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
if *reverse {
idx = k - 1 - idx.min(k - 1);
@@ -397,6 +443,105 @@ mod tests {
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
}
/// The statistics as every rule used to get them: one pass collecting both parts.
fn all_stats_before(sh: &Sheet, ranges: &[RangeRef]) -> RuleStats {
let mut nums = Vec::new();
let mut counts = HashMap::new();
for r in ranges {
let Some(r) = sh.used_range().and_then(|u| r.intersection(&u)) else { continue };
for c in r.iter() {
let v = sh.value(c);
if let Value::Number(n) = v {
nums.push(n);
}
if !v.is_empty() {
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
}
}
}
nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = nums.len().max(1) as f64;
let avg = nums.iter().sum::<f64>() / n;
let std = (nums.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
RuleStats { sorted: nums, avg, std, counts }
}
#[test]
fn needed_statistics_equal_the_full_ones() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "x"], [7, "X"], [3, null], ["=NA()", 2.5], [" ", 9], [-1, "y"]]})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
for ranges in ["A1:B6", "A1:A3", "B2:B9", "D1:E4", "A1:A2"].map(|r| vec![RangeRef::parse(r).unwrap()]) {
let full = all_stats_before(sh, &ranges);
let numbers = RuleStats::build(sh, &ranges, StatsNeed::Numbers);
assert_eq!((numbers.sorted, numbers.avg.to_bits(), numbers.std.to_bits()), (full.sorted, full.avg.to_bits(), full.std.to_bits()));
assert_eq!(RuleStats::build(sh, &ranges, StatsNeed::Counts).counts, full.counts);
}
}
#[test]
fn rules_that_read_statistics_still_match() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, "a"], [2, "A"], [3, "b"], [10, "c"], [2, null]]})).unwrap();
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
s.execute("home.conditionalFormat", rule("B1:B5", json!({"type": "duplicate", "preset": "lightRedFill"}))).unwrap();
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "top10", "rank": 1, "preset": "redBorder"}))).unwrap();
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "colorScale"}))).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap());
let fill = |l: Option<CfLook>| l.and_then(|l| l.style).is_some_and(|st| st.fill.pattern == PatternType::Solid);
// Duplicates are case-insensitive texts: "a" and "A".
assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]);
// The top value gets the border, the scale runs from the minimum to the maximum.
assert!(look("A4").and_then(|l| l.style).is_some_and(|st| !st.borders.top.is_none()));
assert!(look("A1").and_then(|l| l.style).is_none_or(|st| st.borders.top.is_none()));
assert_eq!(look("A1").and_then(|l| l.scale_fill), Some([0xF8, 0x69, 0x6B]));
assert_eq!(look("A4").and_then(|l| l.scale_fill), Some([0x63, 0xBE, 0x7B]));
}
/// Looking cells up does work in proportion to the cells asked for, not the rule's range: the
/// statistics are only gathered for rules that read them, and once per rule.
#[test]
fn lookups_do_not_scan_the_range_for_rules_without_statistics() {
let mut s = crate::Session::new();
s.new_workbook();
let rows: Vec<serde_json::Value> = (1..=10_000).map(|i| json!([i, i % 7])).collect();
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute(
"home.conditionalFormat",
json!({"range": "A1:B10000", "rule": {"type": "expression", "formula": "=MOD(ROW(),2)=0", "preset": "lightRedFill"}}),
)
.unwrap();
s.execute(
"home.conditionalFormat",
json!({"range": "A1:B10000", "rule": {"type": "cellIs", "operator": "greater", "value": "5", "preset": "redBorder"}}),
)
.unwrap();
s.execute("home.conditionalFormat", json!({"range": "A1:B10000", "rule": {"type": "noBlanks", "preset": "redBorder"}})).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
SCANNED.with(|n| n.set(0));
for r in 1..=50 {
for col in ["A", "B"] {
cache.look(&d.wb, 0, CellRef::parse(&format!("{col}{r}")).unwrap());
}
}
assert_eq!(SCANNED.with(|n| n.get()), 0);
// A rule that does read them scans its range once, however many cells are looked up.
s.execute("home.conditionalFormat", json!({"range": "A1:A10000", "rule": {"type": "top10", "preset": "redBorder"}})).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
SCANNED.with(|n| n.set(0));
for r in 1..=50 {
cache.look(&d.wb, 0, CellRef::parse(&format!("A{r}")).unwrap());
}
assert_eq!(SCANNED.with(|n| n.get()), 10_000);
}
#[test]
fn cell_value_operator_names() {
let mut s = crate::Session::new();
@@ -410,4 +555,28 @@ mod tests {
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
assert_eq!([look("A1"), look("A2")], [true, false]);
}
#[test]
fn text_rules_skip_error_values() {
let mut s = crate::Session::new();
s.new_workbook();
let values = json!([["=NA()", "=NA()", "=1/0", "=NA()"], ["abc", "#x", "x!", "abc"], ["xyz", "y", "y", "xyz"]]);
s.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
let rule = |range: &str, kind: &str, text: &str| json!({"range": range, "rule": {"type": kind, "text": text, "preset": "lightRedFill"}});
s.execute("home.conditionalFormat", rule("A1:A3", "containsText", "a")).unwrap();
s.execute("home.conditionalFormat", rule("B1:B3", "beginsWith", "#")).unwrap();
s.execute("home.conditionalFormat", rule("C1:C3", "endsWith", "!")).unwrap();
s.execute("home.conditionalFormat", rule("D1:D3", "notContainsText", "a")).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
// Contains "a": abc, never #N/A although its text holds an "a".
assert_eq!([look("A1"), look("A2"), look("A3")], [false, true, false]);
// Begins with "#": #x, never #N/A.
assert_eq!([look("B1"), look("B2"), look("B3")], [false, true, false]);
// Ends with "!": x!, never #DIV/0!.
assert_eq!([look("C1"), look("C2"), look("C3")], [false, true, false]);
// Does not contain "a": #N/A and xyz, not abc.
assert_eq!([look("D1"), look("D2"), look("D3")], [true, false, true]);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Charts as inline SVG for Save as Web Page: the chart crate's drawing primitives, text included.
use std::fmt::Write as _;
use gridcraft_chart::{HAlign, Prim, Rgba, VAlign};
use gridcraft_model::{Chart, Workbook};
use crate::io::esc;
/// Widest or tallest chart written; larger anchors are clamped.
const MAX_SIDE: f32 = 16_384.0;
struct HelveticaMeasure;
impl gridcraft_chart::Measure for HelveticaMeasure {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
gridcraft_pdf::text_width(if bold { gridcraft_pdf::Font::HelveticaBold } else { gridcraft_pdf::Font::Helvetica }, size, text)
}
}
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
if !(w >= 1.0 && h >= 1.0) {
return None;
}
let data = gridcraft_chart::resolve(wb, sheet, chart);
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
Some(prims_svg(&prims, w, h, style))
}
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
let mut out = String::new();
let _ = write!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
);
for p in prims {
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
if *radius > 0.0 {
let _ = write!(out, " rx=\"{}\"", num(*radius));
}
paint(&mut out, *fill, *stroke);
out.push_str("/>");
}
Prim::Line { pts, color, width, dash } => {
let _ = write!(out, "<polyline points=\"{}\"", points(pts));
paint(&mut out, None, Some((*color, *width)));
if *dash {
out.push_str(" stroke-dasharray=\"3 2\"");
}
out.push_str("/>");
}
Prim::Polygon { pts, fill, stroke } => {
let _ = write!(out, "<polygon points=\"{}\"", points(pts));
paint(&mut out, Some(*fill), *stroke);
out.push_str("/>");
}
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
let _ = write!(out, "<path d=\"{}\"", wedge_path(*cx, *cy, *r_outer, *r_inner, *a0, *a1));
paint(&mut out, Some(*fill), *stroke);
out.push_str("/>");
}
Prim::Circle { cx, cy, r, fill, stroke } => {
let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\"", num(*cx), num(*cy), num(*r));
paint(&mut out, *fill, *stroke);
out.push_str("/>");
}
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
// Baseline offsets as in the PDF export.
let dy = match valign {
VAlign::Top => size * 0.78,
VAlign::Middle => size * 0.35,
VAlign::Bottom => -size * 0.21,
};
let anchor = match align {
HAlign::Left => "start",
HAlign::Center => "middle",
HAlign::Right => "end",
};
let _ = write!(out, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));
if *bold {
out.push_str(" font-weight=\"bold\"");
}
if rotation.is_finite() && *rotation != 0.0 {
let _ = write!(out, " transform=\"rotate({} {} {})\"", num(rotation.to_degrees()), num(*x), num(*y));
}
let _ = write!(out, " fill=\"{}\"{}>{}</text>", hex(*color), opacity("fill", *color), esc(text));
}
}
}
out.push_str("</svg>");
out
}
/// Fill and stroke attributes of an open shape element; the caller closes it.
fn paint(out: &mut String, fill: Option<Rgba>, stroke: Option<(Rgba, f32)>) {
match fill {
Some(c) => {
let _ = write!(out, " fill=\"{}\"{}", hex(c), opacity("fill", c));
}
None => out.push_str(" fill=\"none\""),
}
if let Some((c, w)) = stroke {
let _ = write!(out, " stroke=\"{}\" stroke-width=\"{}\"{}", hex(c), num(w), opacity("stroke", c));
}
}
/// A pie or doughnut sector. Angles run clockwise from 12 o'clock, like the chart crate's.
fn wedge_path(cx: f32, cy: f32, r_outer: f32, r_inner: f32, a0: f32, a1: f32) -> String {
let span = (a1 - a0).clamp(-std::f32::consts::TAU + 0.001, std::f32::consts::TAU - 0.001);
let a1 = a0 + span;
let at = |a: f32, r: f32| format!("{} {}", num(cx + r * a.sin()), num(cy - r * a.cos()));
let large = u8::from(span.abs() > std::f32::consts::PI);
let sweep = u8::from(span >= 0.0);
let mut d = format!("M{} A{r} {r} 0 {large} {sweep} {}", at(a0, r_outer), at(a1, r_outer), r = num(r_outer));
if r_inner > 0.0 {
let _ = write!(d, " L{} A{r} {r} 0 {large} {} {} Z", at(a1, r_inner), 1 - sweep, at(a0, r_inner), r = num(r_inner));
} else {
let _ = write!(d, " L{} {} Z", num(cx), num(cy));
}
d
}
fn points(pts: &[[f32; 2]]) -> String {
pts.iter().map(|p| format!("{},{}", num(p[0]), num(p[1]))).collect::<Vec<_>>().join(" ")
}
fn hex(c: Rgba) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn opacity(attr: &str, c: Rgba) -> String {
if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) }
}
/// A coordinate with two decimals; a non-finite value is 0 so the SVG stays well-formed.
fn num(v: f32) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_primitive_becomes_an_svg_element() {
let red = [255, 0, 0, 255];
let prims = vec![
Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some(red), stroke: None, radius: 0.0 },
Prim::Line { pts: vec![[0.0, 0.0], [5.0, 5.5]], color: red, width: 1.0, dash: true },
Prim::Polygon { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], fill: red, stroke: Some(([0, 0, 0, 255], 1.0)) },
Prim::Wedge { cx: 10.0, cy: 10.0, r_outer: 5.0, r_inner: 2.0, a0: 0.0, a1: 1.0, fill: red, stroke: None },
Prim::Circle { cx: 1.0, cy: 1.0, r: 2.0, fill: Some([0, 0, 255, 128]), stroke: None },
Prim::Text {
x: 5.0,
y: 6.0,
text: "A<B".into(),
size: 10.0,
color: red,
bold: true,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -1.5,
},
];
let svg = prims_svg(&prims, 100.0, 50.0, "left:1px");
for part in [
"<rect x=\"1\" y=\"2\" width=\"3\" height=\"4\" fill=\"#ff0000\"/>",
"<polyline points=\"0,0 5,5.5\" fill=\"none\" stroke=\"#ff0000\" stroke-width=\"1\" stroke-dasharray=\"3 2\"/>",
"<polygon ",
"<path d=\"M10 5 A5 5 0 0 1 ",
"fill-opacity=\"0.50\"",
"text-anchor=\"middle\"",
"font-weight=\"bold\"",
"transform=\"rotate(-85.94 5 6)\"",
">A&lt;B</text>",
] {
assert!(svg.contains(part), "{part} in {svg}");
}
assert!(svg.starts_with("<svg ") && svg.ends_with("</svg>") && svg.contains("style=\"left:1px\""), "{svg}");
}
#[test]
fn non_finite_numbers_do_not_break_the_markup() {
let prims = [Prim::Circle { cx: f32::NAN, cy: f32::INFINITY, r: 1.0, fill: None, stroke: None }];
assert!(prims_svg(&prims, 10.0, 10.0, "").contains("<circle cx=\"0\" cy=\"0\" r=\"1\" fill=\"none\"/>"));
}
#[test]
fn a_full_circle_wedge_keeps_a_drawable_arc() {
let d = wedge_path(0.0, 0.0, 10.0, 0.0, 0.0, std::f32::consts::TAU);
assert!(d.contains("A10 10 0 1 1 ") && d.ends_with("L0 0 Z"), "{d}");
}
}
+166 -42
View File
@@ -11,6 +11,11 @@
const MAX_HTML: usize = 16 * 1024 * 1024;
/// Widest `colspan` honoured (the sheet's column count).
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
/// Tallest `rowspan` honoured (the sheet's row count).
const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
const MAX_HTML_CELLS: usize = 1_000_000;
/// One table cell: its visible text and the number the source recorded for it, if any.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -19,25 +24,28 @@ pub(crate) struct HtmlCell {
pub num: Option<f64>,
}
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`]
/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan`
/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when
/// there is no table.
pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<Vec<HtmlCell>> {
let mut grid = Grid::new(max_rows, max_cols);
if html.len() > MAX_HTML {
return rows;
return grid.rows;
}
let mut cell: Option<(HtmlCell, usize)> = None;
let mut cell: Option<OpenCell> = None;
let mut in_table = false;
let mut rest = html;
while !rest.is_empty() {
while !rest.is_empty() && !grid.full {
let Some(lt) = rest.find('<') else {
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest);
if let Some(c) = cell.as_mut() {
c.cell.text.push_str(rest);
}
break;
};
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest.get(..lt).unwrap_or_default());
if let Some(c) = cell.as_mut() {
c.cell.text.push_str(rest.get(..lt).unwrap_or_default());
}
rest = rest.get(lt..).unwrap_or_default();
if let Some(after) = rest.strip_prefix("<!--") {
@@ -65,49 +73,131 @@ pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
in_table = true;
}
("table", true) => {
finish(&mut rows, &mut cell);
if !rows.is_empty() {
grid.finish(&mut cell);
if !grid.rows.is_empty() {
break;
}
in_table = false;
}
("tr", false) if in_table => {
finish(&mut rows, &mut cell);
rows.push(Vec::new());
grid.finish(&mut cell);
grid.start_row();
}
("td" | "th", false) if in_table => {
finish(&mut rows, &mut cell);
if rows.is_empty() {
rows.push(Vec::new());
}
grid.finish(&mut cell);
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
cell = Some((HtmlCell { text: String::new(), num }, span));
let colspan = span(attrs, "colspan", MAX_SPAN);
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
}
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
("br", _) => {
if let Some((c, _)) = cell.as_mut() {
c.text.push(' ');
if let Some(c) = cell.as_mut() {
c.cell.text.push(' ');
}
}
_ => {}
}
}
finish(&mut rows, &mut cell);
rows
grid.finish(&mut cell);
grid.rows
}
/// Closes the open cell (if any) into the last row, decoding its text.
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
let Some((mut c, span)) = cell.take() else { return };
c.text = decode(&c.text);
if rows.is_empty() {
rows.push(Vec::new());
/// A `colspan`/`rowspan` attribute: at least 1, at most `max` (the sheet's extent), so a hostile
/// "999999999999" is clamped before anything is expanded.
fn span(attrs: &str, name: &str, max: usize) -> usize {
attr(attrs, name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, max)
}
/// A cell whose closing tag hasn't been seen yet.
struct OpenCell {
cell: HtmlCell,
colspan: usize,
rowspan: usize,
}
/// The table being read, with its size limits.
struct Grid {
rows: Vec<Vec<HtmlCell>>,
max_rows: usize,
max_cols: usize,
/// Cells still allowed before the table is cut short.
budget: usize,
/// Per column: the row index (exclusive) down to which a `rowspan` from above covers it.
covered_until: Vec<usize>,
/// The row or cell budget ran out: nothing more is read.
full: bool,
}
impl Grid {
fn new(max_rows: usize, max_cols: usize) -> Self {
let max_rows = max_rows.min(MAX_ROW_SPAN);
let max_cols = max_cols.min(MAX_SPAN);
let budget = MAX_HTML_CELLS.min(max_rows.saturating_mul(max_cols));
Grid { rows: Vec::new(), max_rows, max_cols, budget, covered_until: Vec::new(), full: false }
}
if let Some(row) = rows.last_mut() {
row.push(c);
let room = MAX_SPAN.saturating_sub(row.len());
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
/// Opens a new row, or marks the grid full when the pasted text has no more rows.
fn start_row(&mut self) {
if self.rows.len() >= self.max_rows {
self.full = true;
} else {
self.rows.push(Vec::new());
}
}
/// Appends one cell to the current row; false when the row or the cell budget is used up.
fn push(&mut self, cell: HtmlCell) -> bool {
let Some(row) = self.rows.last_mut() else { return false };
if row.len() >= self.max_cols {
return false;
}
let Some(left) = self.budget.checked_sub(1) else {
self.full = true;
return false;
};
self.budget = left;
row.push(cell);
true
}
/// Closes the open cell (if any) into the current row, decoding its text: first past the
/// columns a `rowspan` above still covers, then the cell, then a placeholder per extra column.
fn finish(&mut self, open: &mut Option<OpenCell>) {
let Some(OpenCell { mut cell, colspan, rowspan }) = open.take() else { return };
if self.rows.is_empty() {
self.start_row();
}
let Some(r) = self.rows.len().checked_sub(1) else { return };
while self.covered_until.get(self.col()).is_some_and(|&until| until > r) {
if !self.push(HtmlCell::default()) {
return;
}
}
let start = self.col();
cell.text = decode(&cell.text);
if !self.push(cell) {
return;
}
for _ in 1..colspan {
if !self.push(HtmlCell::default()) {
break;
}
}
if rowspan > 1 {
let (end, until) = (self.col(), r.saturating_add(rowspan));
if self.covered_until.len() < end {
self.covered_until.resize(end, 0);
}
for u in self.covered_until.get_mut(start..end).into_iter().flatten() {
*u = (*u).max(until);
}
}
}
/// The column the next cell of the current row lands in.
fn col(&self) -> usize {
self.rows.last().map_or(0, Vec::len)
}
}
@@ -276,7 +366,7 @@ mod tests {
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
let rows = table_cells(excel);
let rows = table_cells(excel, 2, 3);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
@@ -286,10 +376,10 @@ mod tests {
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
@@ -306,10 +396,44 @@ mod tests {
"<style>",
"<table><table><td>",
] {
let _ = table_cells(html);
let _ = table_cells(html, MAX_ROW_SPAN, MAX_SPAN);
}
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>", 1, 1)[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>", 1, usize::MAX)[0].len() <= MAX_SPAN);
}
#[test]
fn huge_spans_stay_within_the_cell_budget() {
// #2171: 100,000 rows of `colspan=16384` once expanded to ~1.6 billion cells (OOM). Even
// with the sheet's full extent as the limit, at most MAX_HTML_CELLS are materialized.
let row = "<tr><td colspan=16384 rowspan=99999999999 x:num='1'>1</td><td colspan=4294967296>2</td></tr>";
let html = format!("<table>{}</table>", row.repeat(100_000));
let rows = table_cells(&html, usize::MAX, usize::MAX);
let cells: usize = rows.iter().map(Vec::len).sum();
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
// Bounded by the pasted text's shape, the same markup yields just that shape.
let rows = table_cells(&html, 3, 2);
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [2, 2, 2]);
assert_eq!(rows[0][0].num, Some(1.0));
assert!(table_cells(&html, 0, 5).is_empty());
assert!(table_cells(&html, 4, 0).iter().all(Vec::is_empty));
}
#[test]
fn rowspan_keeps_later_rows_in_their_columns() {
// A merged A1:A2 is one `rowspan=2` cell; row 2 omits it, and its text has an empty field there.
let html = r#"<table><tr><td rowspan=2 colspan=2>Merged</td><td x:num="1">1</td></tr>
<tr><td x:num="2">2</td></tr><tr><td x:num="3">3</td><td>c</td></tr></table>"#;
let rows = table_cells(html, 3, 3);
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [3, 3, 2]);
assert_eq!(rows[0][2].num, Some(1.0));
assert_eq!((rows[1][0].clone(), rows[1][1].clone()), (HtmlCell::default(), HtmlCell::default()));
assert_eq!(rows[1][2].num, Some(2.0));
assert_eq!(rows[2][0].num, Some(3.0), "the span ends after its second row");
}
#[test]
+47 -7
View File
@@ -782,15 +782,24 @@ fn forecast_grid(x: &[f64], f: &[Option<f64>], se_window: usize, p: &Json) -> Gr
g.put(idx(i), 0, fi.map(Value::number).unwrap_or_else(na));
}
if flag(p, "standardErrors") {
// A running sum of squared residuals over the last `se_window` points: O(n), not
// O(n·window). A point without a forecast restarts it, so every window touching it is #N/A.
let sq = |j: usize| Some((x.get(j)? - (*f.get(j)?)?).powi(2));
let (mut acc, mut run) = (0.0, 0usize);
for i in 0..f.len() {
let start = (i + 1).checked_sub(se_window);
let v = start.and_then(|s0| {
let mut acc = 0.0;
for j in s0..=i {
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
match sq(i) {
Some(t) => {
acc += t;
run += 1;
}
Some((acc / se_window as f64).sqrt())
});
None => (acc, run) = (0.0, 0),
}
if run > se_window {
acc -= i.checked_sub(se_window).and_then(sq).unwrap_or(0.0);
run = se_window;
}
// Rounding in the subtraction can leave a tiny negative sum.
let v = (run == se_window).then(|| (acc.max(0.0) / se_window as f64).sqrt());
g.put(idx(i), 1, v.map(Value::number).unwrap_or_else(na));
}
}
@@ -1190,6 +1199,37 @@ mod tests {
x
}
#[test]
fn forecast_standard_errors_match_the_full_window_rescan() {
// #1043: the running sum must give what re-adding every window gave, gaps included.
let x: [f64; 11] = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 1.0, 3.0, 8.0, 6.0, 2.0];
let f = [None, Some(3.0), Some(4.5), None, Some(4.0), Some(6.5), Some(2.0), Some(2.5), Some(7.0), Some(5.0), Some(4.0)];
let rescan = |i: usize, w: usize| -> Value {
let v = (i + 1).checked_sub(w).and_then(|s0| {
let mut acc = 0.0;
for j in s0..=i {
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
}
Some((acc / w as f64).sqrt())
});
v.map(Value::number).unwrap_or(Value::Error(gridcraft_core::CellError::NA))
};
for w in 1..=5 {
let g = super::forecast_grid(&x, &f, w, &json!({"standardErrors": true}));
for i in 0..f.len() {
let (got, want) = (g.cells.get(&(i as u32, 1)).cloned().unwrap_or(Value::Empty), rescan(i, w));
match (got.as_f64(), want.as_f64()) {
(Some(a), Some(b)) => assert!(close(a, b, 1e-12), "window {w}, row {i}: {a} vs {b}"),
_ => assert_eq!(got, want, "window {w}, row {i}"),
}
}
}
// Row 3 has no forecast: windows through it are #N/A, the next full window after it is not.
let g = super::forecast_grid(&x, &f, 3, &json!({"standardErrors": true}));
assert_eq!(g.cells.get(&(5, 1)), Some(&Value::Error(gridcraft_core::CellError::NA)));
assert!(g.cells.get(&(6, 1)).and_then(Value::as_f64).is_some());
}
#[test]
fn descriptive_statistics_of_one_to_five() {
// 1..5: mean 3, s = √2.5, kurtosis −1.2, skewness 0 (textbook values).
+439 -55
View File
@@ -80,13 +80,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Group",
["Data", "Outline"],
Some("Alt+Shift+Right"),
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default)}",
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
has_doc,
|s, p| group(s, p, true)
),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols}", has_doc, |s, p| group(
s, p, false
)),
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
group(s, p, false)
}),
cmd!(
"data.hideDetail",
"Hide Detail",
@@ -147,6 +147,23 @@ fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(current_region(sh, d.selection.active))
}
/// The sheet and range a data command works on: the sheet `sheet` or a `Sheet!` prefix on `range`
/// names, else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if let Some(t) = str_param(p, "range")
&& let (Some(name), _) = split_sheet(t)
&& d.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
}
/// Excel's header guess: the first row is text over a column of numbers, or is bold.
fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool {
if let Some(t) = sh.table_at(r.start) {
@@ -182,19 +199,36 @@ struct SortKey {
list: Option<Vec<String>>,
}
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
if let Some(t) = str_param(p, "range")
&& let (Some(name), _) = split_sheet(t)
&& d.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
}
if sheet != d.wb.active_sheet && p.get("range").is_none() {
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
}
Ok((sheet, data_range(s, p)?))
}
fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = sort_target(s, p)?;
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = guess_header(&d.wb, sh, r);
do_sort(s, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
}
fn sort(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = sort_target(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
let by_cols = str_param(p, "orientation") == Some("columns");
let case = bool_param(p, "matchCase").unwrap_or(false);
@@ -228,10 +262,12 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
if keys.len() > 64 {
return Err(bad("data.sort", "too many sort levels (64 max)"));
}
do_sort(s, r, header, keys, by_cols, case)
do_sort(s, sheet, r, header, keys, by_cols, case)
}
fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
let body = if by_cols {
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
} else if header && r.height() > 1 {
@@ -242,7 +278,6 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
if body.count() > 20_000_000 {
return Err(bad("data.sort", "range too large"));
}
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
@@ -295,6 +330,21 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
}
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
// describe — the way cell pictures and row heights do. Collect them before inserting,
// so a swap can't resurrect one from a destination that is also a source.
let side: Vec<(CellRef, Comment)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
.collect();
let links: Vec<(CellRef, Hyperlink)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
.collect();
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
let mut pictures = Vec::new();
for (dst_i, &src_i) in order.iter().enumerate() {
@@ -354,6 +404,18 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
}
}
}
// Every cell of the body is the destination of exactly one source cell, so clearing the
// body and inserting the permuted entries is the same permutation the cells went through.
for c in body.iter() {
shm.comments.remove(&c);
shm.hyperlinks.remove(&c);
}
for (at, cm) in side {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
shm.cell_pictures.extend(pictures);
for (i, h) in heights.into_iter().enumerate() {
let row = body.start.row + i as u32;
@@ -437,10 +499,12 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
// ---------------------------------------------------------------- filter
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let (sheet, r) = data_target(s, p, "data.filter")?;
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
let in_table =
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone()));
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
if let Some(name) = in_table {
return edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
@@ -473,8 +537,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let mut r = data_range(s, p)?;
let (sheet, mut r) = data_target(s, p, "data.filter")?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
@@ -492,6 +555,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let af = match &sh.autofilter {
Some(a) => a.clone(),
@@ -551,6 +615,8 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
let num = gridcraft_core::parse::parse_number_text(crit);
let cmp = match (v.as_f64(), num) {
(Some(a), Some(b)) => a.partial_cmp(&b),
// A numeric ordering comparison only matches numbers; blanks and text never do.
(None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None,
_ => Some(gridcraft_core::compare_text(text, crit)),
};
let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase());
@@ -581,6 +647,11 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
}
}
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
@@ -614,7 +685,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let text = crate::display::cell_text(wb, sh, c);
let ok = match crit {
FilterCriterion::Values { values, blanks } => {
if v.is_empty() {
if filter_blank(&v) {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
@@ -701,10 +772,11 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
let c = CellRef::new(row, col);
let v = sh.value(c);
let t = if v.is_empty() { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
let blank = filter_blank(&v);
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
let on = match selected {
Some(FilterCriterion::Values { values, blanks }) => {
if v.is_empty() {
if blank {
*blanks
} else {
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
@@ -712,7 +784,8 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
}
_ => true,
};
seen.entry((sort_key(&v), t)).or_insert(on);
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
seen.entry((key, t)).or_insert(on);
}
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
@@ -736,9 +809,9 @@ fn sort_key(v: &Value) -> (u8, i64, String) {
// ---------------------------------------------------------------- tools
fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Rows and columns past the data are blank: only the part with data is compared (whole
// columns compared a million blank rows and reported them as removed duplicates).
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
@@ -748,7 +821,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
let sheet = d.wb.active_sheet;
// With no column to compare, every row key is empty: the first row would survive and every
// other row would be deleted as a duplicate. A column outside the range compares data the
// user didn't select. Excel refuses both.
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
}
let start = r.start.row + header as u32;
let mut removed = 0;
edit(s, |cx| {
@@ -774,6 +852,24 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
})
.collect();
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
// describe them move with them; the ones on removed rows are dropped with the row.
let notes: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
})
.collect();
let links: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
})
.collect();
let rows: Vec<Vec<Option<Cell>>> =
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
let shm = cx.sheet_mut(sheet)?;
@@ -782,6 +878,13 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
shm.remove_cell(CellRef::new(row, c));
}
}
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
let at = CellRef::new(row, c);
shm.comments.remove(&at);
shm.hyperlinks.remove(&at);
}
}
for (i, cells) in rows.into_iter().enumerate() {
for (j, cell) in cells.into_iter().enumerate() {
if let Some(cell) = cell {
@@ -790,6 +893,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
}
}
shm.cell_pictures.extend(pictures);
for (at, cm) in notes {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
cx.changed.push((sheet, c));
}
@@ -824,7 +933,10 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
Some(w) => {
let chars: Vec<char> = text.chars().collect();
let mut cuts = vec![0];
cuts.extend(w.iter().copied().filter(|x| *x < chars.len()));
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
breaks.sort_unstable();
breaks.dedup();
cuts.extend(breaks);
cuts.push(chars.len());
cuts.windows(2)
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
@@ -949,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
"time" => ValidationKind::Time,
"textLength" => ValidationKind::TextLength,
"custom" => ValidationKind::Custom,
_ => ValidationKind::Any,
"any" => ValidationKind::Any,
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
};
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
return Err(bad("data.validation", "a list validation needs `formula1`"));
}
const OPERATORS: [&str; 12] = [
"between",
"notBetween",
"equal",
"notEqual",
"greater",
"greaterThan",
"less",
"lessThan",
"greaterOrEqual",
"greaterThanOrEqual",
"lessOrEqual",
"lessThanOrEqual",
];
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
}
let error_style = match str_param(p, "errorStyle") {
None | Some("stop") => ErrorStyle::Stop,
Some("warning") => ErrorStyle::Warning,
Some("information") => ErrorStyle::Information,
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
};
let f1 = str_param(p, "formula1").unwrap_or("");
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
let dv = Validation {
ranges: ranges.clone(),
kind,
op: op_param(str_param(p, "operator")),
f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(),
f1,
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
@@ -964,11 +1106,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
show_input: true,
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
error_style: match str_param(p, "errorStyle") {
Some("warning") => ErrorStyle::Warning,
Some("information") => ErrorStyle::Information,
_ => ErrorStyle::Stop,
},
error_style,
show_error: true,
};
edit(s, |cx| {
@@ -979,14 +1117,36 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
/// than a reference or a name to evaluate.
fn is_literal_list(f: &str) -> bool {
f.starts_with('"')
|| (!f.contains('!')
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
}
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
fn list_source(f: &str) -> String {
let t = f.trim();
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
return f.trim_start_matches('=').to_string();
}
format!("\"{}\"", t.replace('"', "\"\""))
}
/// List entries of a list validation (literal or from a range).
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
let f = dv.f1.trim();
if f.starts_with('"')
|| (!f.contains('!')
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
{
return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
if is_literal_list(f) {
// A quoted list is a string literal: `""` inside stands for one `"`.
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
Some(body) => body.replace("\"\"", "\""),
None => f.trim_matches('"').to_string(),
};
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
match gridcraft_calc::evaluate(wb, sheet, at, f) {
@@ -1127,10 +1287,10 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
// ---------------------------------------------------------------- outline
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
if let Some(r) = str_param(p, "rows").and_then(RangeRef::parse) {
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
return Ok((true, r.start.row, r.end.row));
}
if let Some(r) = str_param(p, "cols").and_then(RangeRef::parse) {
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
return Ok((false, r.start.col, r.end.col));
}
let r = s.doc()?.selection.current();
@@ -1143,9 +1303,13 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
}
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
@@ -1199,7 +1363,7 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
.collect();
for i in g.start..=g.end {
if let Some(e) = map.get_mut(&i) {
e.hidden = hide || inner.iter().any(|o| o.contains(i));
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if hide {
@@ -1213,6 +1377,16 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
})
}
/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as
/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the
/// group expanded and leave it invisible (#1714).
fn set_hidden(e: &mut LineInfo, hidden: bool) {
e.hidden = hidden;
if !hidden && e.size == Some(0.0) {
e.size = None;
}
}
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
@@ -1231,7 +1405,7 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
let Some(n) = n else { continue };
let groups = outline::groups(map);
for e in map.values_mut().filter(|e| e.outline > 0) {
e.hidden = e.outline >= n;
set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
@@ -1245,7 +1419,8 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
}
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let r = data_range(s, p)?;
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
let func = str_param(p, "function").unwrap_or("sum");
let code = match func {
@@ -1269,13 +1444,13 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
"product" => "Product",
_ => "Total",
};
let sheet = s.doc()?.wb.active_sheet;
// Group boundaries (header in the first row).
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let mut groups: Vec<(u32, u32, String)> = Vec::new();
let mut start = r.start.row + 1;
for row in r.start.row + 1..=r.end.row + 1 {
let first = r.start.row + u32::from(header);
let mut start = first;
for row in first..=r.end.row + 1 {
let cur = sh.value(CellRef::new(row, group_col)).display();
let prev = sh.value(CellRef::new(start, group_col)).display();
if row > r.end.row || cur != prev {
@@ -1286,25 +1461,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
// Insert a total row after each group, bottom-up so indices stay valid.
let mut inserted = 0;
for (g0, g1, name) in groups.iter().rev() {
s.execute("home.insertRows", json!({"rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
let row = g1 + 1;
let lbl = CellRef::new(row, group_col).a1();
s.execute("cell.set", json!({"cell": lbl, "input": format!("{name} {label}")}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
s.execute("cell.set", json!({"cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
s.execute("home.bold", json!({"range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}))?;
s.execute("data.group", json!({"rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
s.execute(
"home.bold",
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
)?;
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
inserted += 1;
}
let last = r.end.row + inserted + 1;
let lbl = CellRef::new(last, group_col).a1();
s.execute("home.insertRows", json!({"rows": format!("{}:{}", last + 1, last + 1)}))?;
s.execute("cell.set", json!({"cell": lbl, "input": format!("Grand {label}")}))?;
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
for c in &cols {
let range = RangeRef::new(CellRef::new(r.start.row + 1, *c), CellRef::new(last - 1, *c)).a1();
s.execute("cell.set", json!({"cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
}
Ok(json!({"groups": inserted}))
}
@@ -1313,6 +1491,37 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
mod tests {
use super::*;
#[test]
fn filter_blanks_include_empty_text_and_formula_results() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B5"})).unwrap();
let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();
assert_eq!(r["hiddenRows"], json!(2));
let vals = filter_values(&s.doc().unwrap().wb, 0, 1);
// The `=""` result joins (Blanks) instead of showing as its own empty entry.
assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}");
let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect();
assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]);
}
#[test]
fn subtotal_honours_header_false() {
// #131: with header:false the first data row was treated as a header and left out of its
// group's subtotal and the grand total.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!("East"));
assert_eq!(get(&mut s, "A3")["text"], json!("East Total"));
assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows");
assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total"));
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
@@ -1345,6 +1554,17 @@ mod tests {
}
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
s.new_workbook();
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
}
#[test]
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
let mut s = Session::new();
@@ -1367,6 +1587,31 @@ mod tests {
assert!(!ok(&mut s, "B1", "150"));
}
#[test]
fn showing_detail_gives_zero_width_columns_their_width_back() {
// #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them.
let mut s = Session::new();
s.new_workbook();
let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() };
let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for c in 2..=4 {
sh.cols.insert(c, hidden);
}
sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() });
s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
for c in 2..=4 {
assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}");
}
// Hiding again hides them; the level buttons show zero-width columns too.
s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0);
Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden);
s.execute("data.showLevels", json!({"cols": 2})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width));
}
#[test]
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
let mut s = Session::new();
@@ -1493,10 +1738,149 @@ mod tests {
}
}
#[test]
fn single_row_and_column_parameters() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
s.execute("home.insertColumns", json!({"cols": "B"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "C3"), json!(4.0));
s.execute("home.hideRows", json!({"rows": "1"})).unwrap();
s.execute("data.group", json!({"cols": "C"})).unwrap();
assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err());
s.execute("home.deleteRows", json!({"rows": "2"})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap();
assert_eq!(v(&mut s, "B2"), json!(4.0));
}
#[test]
fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() {
let cases: [(Json, [&str; 4]); 5] = [
(json!([6, 3]), ["abc", "def", "ghij", ""]),
(json!([3, 3]), ["abc", "defghij", "", ""]),
(json!([0]), ["abcdefghij", "", "", ""]),
(json!([20]), ["abcdefghij", "", "", ""]),
(json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]),
];
for (widths, want) in cases {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap();
s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let got: Vec<String> = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect();
assert_eq!(got, want, "{widths}");
}
}
#[test]
fn split() {
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
}
#[test]
fn remove_duplicates_without_a_valid_column_is_refused() {
// `columns: []` (or only unusable names) used to compare nothing and delete every row
// but the first.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
for columns in [json!([]), json!(["ZZZ"])] {
let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err();
assert!(err.to_string().contains("columns"), "{err}");
}
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
}
#[test]
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
let rules = [
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
("C1", " 1,2,3 ", r#""1,2,3""#),
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
("F1", "$K$1:$K$3", "$K$1:$K$3"),
("G1", "=Colors", "Colors"),
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
];
for (cell, source, _) in rules {
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
}
let accepted = [
("A1", "In progress"),
("B1", "Closed"),
("C1", "2"),
("D1", r#"Say "hi""#),
("E1", "Green"),
("F1", "Blue"),
("G1", "Red"),
("H1", "Green"),
];
let check = |wb: &Workbook| {
for (cell, input) in accepted {
let at = CellRef::parse(cell).unwrap();
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
}
};
let wb = s.doc().unwrap().wb.clone();
check(&wb);
let sheet_xml = |wb: &Workbook| {
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
xml
};
let xml = sheet_xml(&wb);
for (cell, _, saved) in rules {
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
}
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
check(&reopened);
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
}
#[test]
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
// with no error.
let mut s = Session::new();
s.new_workbook();
for params in [
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
] {
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
assert!(e.contains("unknown"), "{params}: {e}");
}
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
for params in [
json!({"range": "A1", "type": "any"}),
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
] {
s.execute("data.validation", params).unwrap();
}
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert_eq!(sh.validations.len(), 3);
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
}
}
+305 -35
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
has_doc,
range_set_values
),
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
has_doc,
auto_fill
),
@@ -138,18 +138,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
let body = input.strip_prefix('=').unwrap_or(input);
// Excel closes missing parentheses for you.
let mut text = body.to_string();
let mut parsed = gridcraft_formula::parse(&text);
for _ in 0..8 {
if parsed.is_ok() {
break;
}
text.push(')');
parsed = gridcraft_formula::parse(&text);
}
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
let expr = wb.name_call_case(expr);
let expr = wb.name_call_case(parse_typed_formula(body)?);
check_arg_counts(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
@@ -176,7 +165,17 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
}
return Ok(Some(cell));
}
let parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
if parsed.format.is_none()
&& !input.contains('%')
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
&& let Value::Number(n) = parsed.value
&& n.abs() >= 1.0
{
parsed.value = Value::Number(n / 100.0);
}
let mut style = style;
if let Some(code) = parsed.format {
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
@@ -191,11 +190,19 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
Ok(Some(Cell { value: parsed.value, formula: None, style }))
}
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
/// and refuses a formula that still doesn't parse.
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
// Excel closes missing parentheses for you.
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
}
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
/// unknown functions are skipped.
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut bad = None;
expr.walk(&mut |e| {
if bad.is_none()
@@ -292,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
@@ -314,6 +330,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
@@ -338,20 +365,31 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let input = str_param(p, "input").unwrap_or("").to_string();
let origin = s.doc()?.selection.active;
edit(s, |cx| {
let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
// Each cell gets what typing the input there gives: the automatic number format where
// it's General, its own format otherwise, text where it's Text. That depends only on
// the cell's style, so the input is parsed once per style.
let mut by_style: std::collections::HashMap<StyleId, Option<Cell>> = std::collections::HashMap::new();
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
for at in r.iter() {
let mut cell = base.clone().unwrap_or_default();
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
let base = match by_style.get(&style) {
Some(c) => c.clone(),
None => {
let c = input_to_cell(&input, sheet, at, &mut cx.wb)?;
by_style.insert(style, c.clone());
c
}
};
let mut cell = base.unwrap_or_default();
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style);
cx.sheet_mut(sheet)?.set_cell(at, cell);
cx.touch(sheet, at);
}
@@ -362,6 +400,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- selection
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
let Some(p) = sheet.protection.as_ref() else { return true };
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
}
fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("sheet").is_some() {
let i = target_sheet(s, p)?;
@@ -379,6 +423,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
if !cell_selectable(&d.wb, sh, sel.active) {
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
}
}
d.selection = sel;
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
@@ -447,6 +494,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
{
to = m.start;
}
if !extend && !cell_selectable(&d.wb, sheet, to) {
// Step on to the next selectable cell in the same direction; stay put if there is none.
let (sr, sc) = (dr.signum(), dc.signum());
let mut t = to;
let mut found = false;
if !jmp && (sr, sc) != (0, 0) {
for _ in 0..10_000 {
let n = t.offset_clamped(sr, sc);
if n == t {
break;
}
t = n;
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
found = true;
break;
}
}
}
if !found {
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
}
to = t;
}
if extend {
sel.extend_to(to);
sel.expand_merges(sheet);
@@ -514,7 +584,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
} else if let Some((si, ti)) = d.wb.table(reference) {
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
} else {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
@@ -601,11 +671,13 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.redo.push(cur);
let cur_wb = d.wb.clone();
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
let cur_selection = e.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
@@ -620,11 +692,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.undo.push(cur);
let cur_wb = d.wb.clone();
let cur_selection = d.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
@@ -793,10 +866,11 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
// only as far as the text's own shape, so spans in the markup can't expand without bound.
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
@@ -936,6 +1010,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let src_wb = clip.wb.clone();
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
let src_sheet_name = src_sheet.name.clone();
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
}
edit(s, |cx| {
// Styles from another workbook must be re-interned.
let mut style_map = std::collections::HashMap::new();
@@ -952,6 +1030,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let source = cx.sheet_mut(clip.sheet)?;
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
let taken = source.take_cells(src);
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
// with the block: the destination is written from the clipboard snapshot taken
// before the cut, so removing them here loses nothing and leaves the old place
// clean instead of leaving one merged block and one note behind.
source.comments.retain(|c, _| !src.contains(*c));
source.hyperlinks.retain(|c, _| !src.contains(*c));
source.merges.retain(|m| !src.contains_range(m));
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
@@ -999,11 +1084,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula::from_expr(moved)))
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
@@ -1130,6 +1216,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
@@ -1205,12 +1294,13 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
{
let mut nf = Formula::from_expr(new);
nf.array = array;
nf.dynamic = f.dynamic;
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if let Ok(expr) = gridcraft_formula::parse(&n.formula) {
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
n.formula = gridcraft_formula::print(&new);
@@ -1246,6 +1336,9 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
@@ -1284,10 +1377,24 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "formats") {
sh.merges.retain(|m| !r.contains_range(m));
}
for c in r.iter().take(1_000_000) {
cx.changed.push((sheet, c));
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m);
unmerged |= !keep;
keep
});
if unmerged {
// An empty merge can block a spill without storing any cells in this range.
// As in Unmerge, retry blocked formula anchors that could reach the freed area.
let reach = RangeRef::new(CellRef::new(0, 0), r.end);
let blocked: Vec<_> = sh
.cells
.iter_range(reach)
.filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
.map(|(c, _)| (sheet, c))
.collect();
cx.changed.extend(blocked);
}
}
}
Ok(Json::Null)
@@ -1306,7 +1413,7 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
None => return Ok(Json::Null),
}
}
if r.count() > 5_000_000 {
if r.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.fill", "range too large"));
}
// With one row selected, Fill Down copies from the row above (Excel behaviour).
@@ -1340,11 +1447,18 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
_ => crate::fill::FillMode::Series,
};
if target.count() > 5_000_000 || src.count() > 5_000_000 {
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
return Err(bad("edit.autoFill", "range too large"));
}
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
// into whole columns (`A:A`).
if !crate::fill::holds_source_in_corner(src, target) {
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
}
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, target, mode)?;
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
@@ -1529,10 +1643,24 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"replaced": n}))
}
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
/// byte positions in the folded text still line up with the original's character boundaries.
fn folds_in_place(s: &str) -> bool {
s.chars().all(|c| {
let mut folded = c.to_lowercase();
let first = folded.next();
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
})
}
fn replace_ci(text: &str, what: &str, with: &str) -> String {
let lower = text.to_lowercase();
let w = what.to_lowercase();
if lower.len() != text.len() || w.is_empty() {
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
// boundary: a length check alone misses foldings that keep the total length while shifting
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
return text.replace(what, with);
}
let mut out = String::new();
@@ -1557,6 +1685,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
let (h, w) = (src.height(), src.width());
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
// Whole rows or columns take a line format, as the ribbon's formatting commands give
// them, instead of a style on each of their cells (a column alone has a million). The
// line format comes from the first cell of the source pattern across the line; cells
// already stored follow the pattern. Row heights and column widths stay as they are.
let rows = dest.is_full_rows() && !dest.is_full_cols();
let cols = dest.is_full_cols() && !dest.is_full_rows();
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
if rows || cols {
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
for line in lines {
let sl = src_first + (line - first) % n;
// The source cell for position `k` along the line.
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
let line_style = if src_full {
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
} else {
Some(src_sheet.style_id(at(0)))
};
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
let (dest_line, src_line) = if rows {
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
} else {
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
};
let mut cells: Vec<CellRef> =
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
if src_full {
cells.extend(
src_sheet
.cells
.iter_range(src_line)
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
);
}
if cols {
// A row format outranks a column format: the column's cells in formatted
// rows need a style of their own.
cells.extend(
cx.wb
.sheet(dest_sheet)
.into_iter()
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
);
}
let sh = cx.sheet_mut(dest_sheet)?;
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
for c in cells {
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
cx.sheet_mut(dest_sheet)?.set_style(c, st);
cx.touch(dest_sheet, c);
}
}
*cx.sel = crate::Selection::range(dest);
return Ok(());
}
for c in dest.iter().take(2_000_000) {
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
let st = src_sheet.style_id(sc);
@@ -1604,6 +1789,55 @@ mod tests {
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
@@ -1675,8 +1909,44 @@ mod tests {
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
}
#[test]
fn cut_paste_that_overflows_the_grid_moves_nothing() {
// #997: the source was emptied first and the cells that fell off the grid were skipped.
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed, the second row would be past the last row.
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
// Not transposed it still fits in column XFD.
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
}
#[test]
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
// no longer line up with the original, and the old slicing dropped the tail.
let mut s = Session::new();
s.new_workbook();
let text = "\u{212A}\u{0130}\u{0130}";
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
}
}
+98 -9
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rotate",
["Page Layout", "Arrange"],
None,
"{kind: shape, id, flip?: horizontal|vertical} (swaps width/height for 90° turns)",
"{kind: shape, id} (swaps width/height for a 90° turn; `flip` is rejected: shapes have no flip state)",
has_doc,
rotate
),
@@ -193,7 +193,24 @@ pub fn specs() -> Vec<CommandSpec> {
new_script
),
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
cmd!(
"automate.runScript",
"Run Script",
[],
None,
"{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
has_doc,
run_script
),
cmd!(
noundo "automate.trustScripts",
"Trust Scripts",
["Automate", "Scripting Tools"],
None,
"{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
has_doc,
trust_scripts
),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
// Cell controls, themes, borders, styles
cmd!(
@@ -351,7 +368,7 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
/// Element ▸ Trendline choices, plus the Format Trendline options).
fn trendline_param(p: &Json) -> Result<Trendline> {
let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
let mut t = Trendline {
forward: num("forward"),
backward: num("backward"),
@@ -662,6 +679,9 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
}
fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("flip").is_some_and(|f| !f.is_null()) {
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
}
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
@@ -782,6 +802,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?.clone();
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
copy.selection = d.selection.clone();
copy.scripts_trusted = d.scripts_trusted;
let i = s.add_document(copy);
Ok(json!({"index": i}))
}
@@ -828,6 +849,9 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
return Err(EngineError::Other(
@@ -970,10 +994,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
}
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
let d = s.doc()?;
let (wb, trusted) = (&d.wb, d.scripts_trusted);
let all = stash_get(wb, SCRIPTS_NAME);
let mut list: Vec<Json> =
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
let mut list: Vec<Json> = all
.as_object()
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
.unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
@@ -982,17 +1009,79 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
Ok(Json::Array(list))
}
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
}
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
/// [`run_script`] drops any `OpenUrl` request a step raises.
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
return Some(format!("scripts can't pass a file `path` to `{id}`"));
}
None
}
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
let trusted = match p.get("trusted") {
None => true,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
};
s.doc_mut()?.scripts_trusted = trusted;
Ok(json!({"trusted": trusted}))
}
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
let d = s.doc()?;
let all = stash_get(&d.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
return Err(bad(
"automate.runScript",
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
));
}
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if let Some(why) = denied_script_step(id, &st["params"]) {
return Err(bad("automate.runScript", why));
}
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
for st in steps.iter().take(100_000) {
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
s.execute(id, st["params"].clone())?;
let before = s.ui_requests.len();
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
let mut i = 0;
s.ui_requests.retain(|r| {
i += 1;
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
n += 1;
}
Ok(json!({"ran": n}))
+18 -3
View File
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook and {csvAsText?: bool} to keep every csv/tsv/txt field as text, with no number or date conversion (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -56,8 +56,15 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
let (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
// What `CELL("filename")` reports (#1489). A workbook opened from bytes knows its name but
// never a folder, so it reports `[name]Sheet` with none; an import reports the file it came
// from, as Excel does, even though Save must still ask where to put the .xlsx.
wb.source_path = match str_param(p, "path") {
Some(path) => crate::io::source_path(path),
None => Some(std::path::PathBuf::from(&name)),
};
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
@@ -122,6 +129,13 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
crate::io::write_file(&path, &bytes)?;
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
// The new file is the workbook's own now, so `CELL("filename")` reports it (#1489). Set
// before the `saved` snapshot, or the workbook would read as dirty the moment it was
// saved; and only when it changes, so an ordinary Save does not clone the workbook.
let file = crate::io::source_path(&path);
if d.wb.source_path != file {
std::sync::Arc::make_mut(&mut d.wb).source_path = file;
}
d.saved = d.wb.clone();
d.path = Some(path.clone());
}
@@ -139,8 +153,9 @@ fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
let fmt = str_param(p, "format").unwrap_or("xlsx");
let d = s.doc()?;
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
// The file name goes into a PivotChart's link to its PivotTable.
let bytes = crate::io::save_bytes(&d.wb, &name)?;
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
+1
View File
@@ -411,6 +411,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
color: None,
secondary: false,
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
})
+175 -50
View File
@@ -133,9 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
number_format
),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(
s, p, ACCOUNTING
)),
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, accounting),
cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
@@ -156,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Row Height…",
["Home", "Cells", "Format"],
None,
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
"{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (pixels) | points: 15}",
has_doc,
row_height
),
@@ -165,28 +163,34 @@ pub fn specs() -> Vec<CommandSpec> {
"Column Width…",
["Home", "Cells", "Format"],
None,
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
"{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (pixels) | chars: 8.43}",
has_doc,
col_width
),
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows),
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols),
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width),
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide(
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows),
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols),
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64 (pixels)}", has_doc, default_width),
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide(
s, p, true, true
)),
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide(
s, p, false, true
)),
cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide(
s, p, true, false
)),
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| {
hide(s, p, false, true)
}),
cmd!(
"home.unhideRows",
"Unhide Rows",
["Home", "Cells", "Format", "Hide & Unhide"],
Some("Cmd+Shift+9"),
"{sheet?, rows?}",
has_doc,
|s, p| hide(s, p, true, false)
),
cmd!(
"home.unhideColumns",
"Unhide Columns",
["Home", "Cells", "Format", "Hide & Unhide"],
Some("Cmd+Shift+0"),
"{cols?}",
"{sheet?, cols?}",
has_doc,
|s, p| hide(s, p, false, false)
),
@@ -202,6 +206,23 @@ pub fn specs() -> Vec<CommandSpec> {
const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
/// Accounting Number Format with the currency symbol to use (Excel's own button takes the system
/// currency; `symbol` is how the control channel picks another one, e.g. `"L"` for lempiras).
fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
let symbol = str_param(p, "symbol").unwrap_or("$");
// The symbol goes into a quoted literal: a quote or a backslash would end it, and a control
// character would reach the format's text as-is.
if symbol.contains(['"', '\\']) || symbol.chars().any(char::is_control) {
return Err(bad("home.accounting", "the symbol can't contain a quote, a backslash or a control character"));
}
set_fmt(s, p, &accounting_format(symbol))
}
fn accounting_format(symbol: &str) -> String {
let symbol = format!("\"{symbol}\"");
format!("_({symbol}* #,##0.00_);_({symbol}* \\(#,##0.00\\);_({symbol}* \"-\"??_);_(@_)")
}
fn with(p: &Json, key: &str, v: Json) -> Json {
let mut m = p.as_object().cloned().unwrap_or_default();
m.insert(key.into(), v);
@@ -296,7 +317,11 @@ fn underline(s: &mut Session, p: &Json) -> Result<Json> {
fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
apply_style(s, p, |st| st.font.name = name.clone())
apply_style(s, p, |st| {
st.font.name = name.clone();
st.font.charset = None;
st.font.family = None;
})
}
fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
@@ -488,7 +513,7 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
}
fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
let Some(patch) = p.get("style").cloned() else {
let Some(patch) = p.get("style").map(snake_case_keys) else {
// Menu invocation without params: the UI opens the dialog.
s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({})));
return ok();
@@ -506,6 +531,32 @@ fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
fn snake_case_keys(v: &Json) -> Json {
match v {
Json::Object(m) => Json::Object(
m.iter()
.map(|(k, v)| {
let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
k.chars().fold(String::new(), |mut out, c| {
if c.is_ascii_uppercase() {
out.push('_');
}
out.push(c.to_ascii_lowercase());
out
})
} else {
k.clone()
};
(key, snake_case_keys(v))
})
.collect(),
),
other => other.clone(),
}
}
/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
match (&mut base, patch) {
@@ -933,29 +984,60 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- sizes
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
let (prefix, body) = split_sheet(t);
if let Some(name) = prefix {
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
}
sheet = named;
}
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
return t
let lines = body
.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect();
.collect::<Result<Vec<_>>>()?;
return Ok((sheet, lines));
}
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
let d = s.doc()?;
if sheet != d.wb.active_sheet {
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
}
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
}
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
/// and of the file (`ht` is in points).
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
let h = f64_param(p, "height").ok_or_else(|| bad("home.rowHeight", "missing `height`"))?;
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 409 points.".into()));
}
let lines = line_targets(s, p, true)?;
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
(Some(h), _) => {
if !(0.0..=546.0).contains(&h) {
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
}
h
}
(None, Some(pt)) => {
if !(0.0..=409.5).contains(&pt) {
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
}
pt * 4.0 / 3.0
}
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
};
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for r in a..=b.min(a.saturating_add(1_048_575)) {
@@ -972,16 +1054,22 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
(Some(w), _) => w,
(None, Some(c)) => chars_to_points(c),
_ => return Err(bad("home.columnWidth", "missing `width`")),
(Some(w), _) => {
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
}
w
}
(None, Some(c)) => {
if !(0.0..=255.0).contains(&c) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
chars_to_points(c)
}
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
};
if !(0.0..=1800.0).contains(&w) {
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
}
let lines = line_targets(s, p, false)?;
let (si, lines) = line_targets(s, p, false)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
for c in a..=b {
@@ -994,7 +1082,7 @@ fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Excel column width in characters (of the default font's digit width, 7 px) → points.
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
pub fn chars_to_points(c: f64) -> f64 {
if c <= 0.0 {
0.0
@@ -1008,7 +1096,7 @@ pub fn chars_to_points(c: f64) -> f64 {
pub use gridcraft_model::points_to_chars;
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
/// widths for the default font; the UI refines with real measurement when it autofits).
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
let mut w = 0.0;
for c in text.chars() {
@@ -1025,12 +1113,18 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let lines = line_targets(s, p, false)?;
let (si, lines) = line_targets(s, p, false)?;
let measure = s.text_width.clone();
edit(s, |cx| {
let si = cx.sheet_index();
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
@@ -1041,12 +1135,26 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
let st = cx.wb.styles.get(sh.style_id(cell));
let id = sh.style_id(cell);
let st = cx.wb.styles.get(id);
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
max = max.max(
approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell),
);
let w = match &measure {
Some(m) => {
let known = measured.entry(id).or_default();
match known.get(line) {
Some(w) => *w,
None => {
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
known.insert(line.to_owned(), w);
distinct += 1;
w
}
}
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
}
}
if max > 0.0 {
@@ -1065,9 +1173,8 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
}
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
let lines = line_targets(s, p, true)?;
let (si, lines) = line_targets(s, p, true)?;
edit(s, |cx| {
let si = cx.sheet_index();
for (a, b) in lines {
let b = b.min(a + 100_000);
if let Some(sh) = cx.wb.sheet_mut(si) {
@@ -1087,17 +1194,16 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
let si = target_sheet(s, p)?;
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
Ok(Json::Null)
})
}
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
let lines = line_targets(s, p, rows)?;
let (si, lines) = line_targets(s, p, rows)?;
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (a, b) in lines {
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
@@ -1137,6 +1243,11 @@ mod tests {
assert_eq!(v(&mut s, "D1"), json!(4.0));
}
#[test]
fn the_default_symbol_reproduces_the_accounting_format() {
assert_eq!(accounting_format("$"), ACCOUNTING);
}
#[test]
fn decimal_steps() {
let v = gridcraft_core::Value::Number(1.5);
@@ -1149,6 +1260,20 @@ mod tests {
assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
}
#[test]
fn format_cells_takes_the_documented_keys() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
assert_eq!(st["num_fmt"], "0.0");
assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
// The style's own snake_case keys keep working.
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
}
#[test]
fn width_units() {
assert_eq!(chars_to_points(8.43), 64.0);
+192 -12
View File
@@ -1,6 +1,6 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use serde_json::{Value as Json, json};
@@ -49,11 +49,22 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas),
cmd!(
"formulas.convertToNumber",
"Convert to Number",
[],
None,
"{range?} → {converted} (numbers stored as text in the range or selection become numbers)",
has_doc,
convert_to_number
),
cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking),
cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula),
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate),
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate),
cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now),
cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now),
cmd!(
@@ -229,7 +240,7 @@ fn valid_name(n: &str) -> bool {
if !(first.is_alphabetic() || first == '_' || first == '\\') {
return false;
}
if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
if n.chars().count() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
return false;
}
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
@@ -237,7 +248,7 @@ fn valid_name(n: &str) -> bool {
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
/// accept as a name.
fn is_r1c1(n: &str) -> bool {
pub(crate) fn is_r1c1(n: &str) -> bool {
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
let mut rest = n;
if let Some(r) = rest.strip_prefix(['R', 'r']) {
@@ -265,7 +276,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
let refers = match str_param(p, "refersTo") {
// As in Excel, relative references are relative to the active cell; names keep them
// relative to A1, as files do.
Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) {
Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) {
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
let at = d.selection.active;
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
@@ -280,7 +291,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body)
}
};
if gridcraft_formula::parse(&refers).is_err() {
if gridcraft_formula::parse_name(&refers).is_err() {
return Err(EngineError::Other("There's a problem with this formula.".into()));
}
let comment = str_param(p, "comment").unwrap_or("").to_string();
@@ -348,6 +359,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
let Some(e) = f.expr().and_then(rename) else { continue };
let mut nf = Formula::from_expr(e);
nf.array = f.array;
nf.dynamic = f.dynamic;
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
@@ -355,7 +367,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
}
for n in wb.names.iter_mut() {
if n.scope.is_none_or(applies)
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
&& let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename)
{
n.formula = gridcraft_formula::print(&e);
}
@@ -389,7 +401,9 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
let v = gridcraft_calc::evaluate(&d.wb, sheet, CellRef::default(), &n.formula);
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment})
})
.collect();
@@ -479,6 +493,82 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
Ok(Json::Array(v))
}
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new();
let mut frontier = vec![(sheet, at)];
seen.insert((sheet, at));
while let Some((ks, kc)) = frontier.pop() {
if precedents {
for a in d.calc.graph.precedents_of((ks, kc)) {
if all {
// Walk only the stored cells inside the precedent area (sparse
// range iterator, not the whole sheet): blanks are never selected
// and only formula cells extend the frontier, so a formula late in
// a large range is not skipped.
let Some(sh) = d.wb.sheet(a.sheet) else { continue };
for (c, cell) in sh.cells.iter_range(a.range) {
if matches!(cell.value, Value::Empty) {
continue;
}
if seen.insert((a.sheet, c)) {
found.insert((a.sheet, c.a1()));
if cell.formula.is_some() {
frontier.push((a.sheet, c));
}
}
}
} else {
found.insert((a.sheet, a.range.a1()));
}
}
} else {
let mut deps = Vec::new();
d.calc.graph.dependents(ks, kc, &mut deps);
for k2 in deps {
if all {
if seen.insert(k2) {
found.insert((k2.0, k2.1.a1()));
frontier.push(k2);
}
} else {
found.insert((k2.0, k2.1.a1()));
}
}
}
if !all {
break;
}
}
found.remove(&(sheet, at.a1()));
let name = if precedents { "precedent" } else { "dependent" };
let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else {
return Err(bad("cell", format!("No {name} cells.")));
};
let list: Vec<String> = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect();
let mut params = json!({ "range": list.join(",") });
if si != sheet {
params["sheet"] = json!(si);
}
s.execute("selection.set", params)
}
fn select_precedents(s: &mut Session, p: &Json) -> Result<Json> {
select_trace_cells(s, p, true)
}
fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
select_trace_cells(s, p, false)
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
@@ -489,6 +579,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The number a cell's text stands for when Excel's background check flags it as a number stored
/// as text: a constant (not a formula's result) that would be a number if typed.
pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option<f64> {
match &cell.value {
Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t),
_ => None,
}
}
/// Convert to Number: each number stored as text in the range becomes that number, keeping the
/// cell's format.
fn convert_to_number(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
let mut converted = 0;
for r in &ranges {
let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue };
let found: Vec<(CellRef, f64)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect())
.unwrap_or_default();
let sh = cx.sheet_mut(sheet)?;
for (c, n) in &found {
sh.set_value(*c, Value::number(*n));
}
cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c)));
converted += found.len();
}
Ok(json!({"converted": converted}))
})
}
fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let mut out = Vec::new();
@@ -528,23 +652,30 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let mut steps = Vec::new();
let mut nodes = Vec::new();
expr.walk(&mut |e| {
// Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the
// rest of the chain each time (quadratic memory).
if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) {
nodes.push(e.clone());
nodes.push(e);
}
});
for e in nodes.into_iter().rev().take(200) {
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e);
steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)}));
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e);
steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)}));
}
let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr);
Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)}))
}
/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it
/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says
/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel.
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
@@ -610,4 +741,53 @@ mod tests {
let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
}
#[test]
fn arabic_defined_names_count_characters_not_bytes() {
let mut s = Session::new();
s.new_workbook();
let arabic = |n: usize| "س".repeat(n);
for ok in [200, 255] {
s.execute("formulas.defineName", json!({"name": arabic(ok), "refersTo": "=1"})).unwrap();
}
assert!(s.execute("formulas.defineName", json!({"name": arabic(256), "refersTo": "=1"})).is_err());
s.execute("range.setValues", json!({"range": "A1", "values": [[format!("ع{}", arabic(199))], [1]]})).unwrap();
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap();
assert_eq!(r["created"], 1);
}
#[test]
fn names_referring_to_several_ranges() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
// Excel writes and accepts a union in a name without parentheses.
let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2");
for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "E1"), json!(15.0));
assert_eq!(v(&mut s, "E2"), json!(2.0));
assert_eq!(v(&mut s, "E3"), json!(1.0));
assert_eq!(v(&mut s, "E4"), json!(7.0));
// Renaming the sheet follows every area, and the definition keeps Excel's form.
s.execute("sheet.rename", json!({"name": "Data"})).unwrap();
let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone());
assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2"));
assert_eq!(v(&mut s, "E1"), json!(15.0));
}
#[test]
fn name_manager_previews_a_name_over_several_ranges() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
let list = s.execute("formulas.nameManager", json!({})).unwrap();
let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap();
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
}
+268 -32
View File
@@ -2,9 +2,11 @@
//! conditional formatting (Home › Styles) and symbols.
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::sheet::{rewrite, shift_cell_features};
use super::*;
use crate::selection::current_region;
@@ -24,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Format as Table",
["Home", "Styles"],
None,
"{range?, style: \"TableStyleMedium2\", header?}",
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
has_doc,
insert_table
format_as_table
),
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
s, p, "totals"
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -188,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Conditional Formatting",
["Home", "Styles"],
None,
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}",
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}",
has_doc,
add_cf
),
@@ -224,6 +226,17 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(current_region(sh, d.selection.active))
}
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
return target_ranges(s, p);
}
let d = s.doc()?;
if d.selection.ranges.len() > 1 {
return Ok(d.selection.ranges.clone());
}
Ok(vec![block(s, p)?])
}
// ---------------------------------------------------------------- tables
fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
@@ -276,13 +289,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
}
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
.into_iter()
.filter_map(|(c, v)| {
let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) };
dest.map(|c| (c, v))
})
.collect();
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
// reference (on any sheet) at the cells that moved.
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
}
@@ -338,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
@@ -466,11 +494,13 @@ fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
}
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
/// structured reference, and the writer would rename the table on save.
/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
/// read as an operator inside a structured reference, and the writer would rename the table on save.
fn valid_table_name(name: &str) -> bool {
name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
name.chars().count() <= 255
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
&& CellRef::parse(name).is_none()
&& !super::formulas::is_r1c1(name)
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
}
@@ -480,7 +510,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
if !valid_table_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
let wb = &s.doc()?.wb;
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
edit(s, |cx| {
@@ -603,6 +634,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
@@ -618,6 +650,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
});
@@ -626,6 +659,36 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
out
}
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
if areas.len() == 1 {
let by_rows = first.width() > first.height() + 1;
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
}
let valid_shape = first.width() == 1
&& first.height() > 1
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
if !valid_shape {
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
}
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
let mut series = Vec::with_capacity(areas.len() - 1);
for area in &areas[1..] {
let mut area_series = series_from_range(sh, *area, false);
let Some(mut item) = area_series.pop() else {
return Err(EngineError::Other("Select data for the chart first.".into()));
};
if item.name.is_none() {
return Err(bad("insert.chart", "multi-area series columns require headers"));
}
item.categories = category.clone();
series.push(item);
}
Ok((bounds, false, series))
}
/// A scatter plot of a block without category labels takes its first column (or row) as the X
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
@@ -646,15 +709,14 @@ const MAX_CHART_CELLS: u64 = 1_000_000;
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let by_rows = r.width() > r.height() + 1;
let series = series_from_range(sh, r, by_rows);
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
@@ -706,8 +768,8 @@ pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'st
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
let areas = chart_areas(s, p)?;
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
@@ -720,18 +782,14 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Excel plots series by the longer dimension (more rows → series in columns).
let data_h = r.height();
let data_w = r.width();
let by_rows = data_w > data_h + 1;
let kind = chart_kind(&t, &sub);
let mut series = series_from_range(sh, r, by_rows);
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
scatter_x_from_first(kind, &mut series);
let id = d.wb.next_object_id();
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
if series.len() == 1 {
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
@@ -758,7 +816,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
style: 1,
x_title: None,
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
by_rows,
pivot: None,
};
@@ -788,12 +846,25 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let at = cell_param_on(s, p, "at", si)?;
let quartiles = p
.get("quartiles")
.map(|v| match v.as_str() {
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
})
.transpose()?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
if let Some(t) = str_param(p, "type") {
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
}
if let Some(method) = quartiles {
for series in &mut c.series {
series.quartiles = method;
}
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(str::to_string);
}
@@ -977,7 +1048,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
},
data,
data: data.into(),
mime,
alt: str_param(p, "alt").unwrap_or("").into(),
};
@@ -1249,7 +1320,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
edit(s, |cx| {
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
Comment {
author: author.clone(),
text: text.clone(),
replies: vec![],
threaded,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
},
);
Ok(json!({"cell": at.a1()}))
})
@@ -1334,6 +1414,110 @@ fn preset_style(name: &str) -> Style {
s
}
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
fn is_string_literal(v: &str) -> bool {
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
}
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
fn cf_style_json(st: &Json) -> Result<Json> {
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
let err = |m: String| bad("home.conditionalFormat", m);
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
let mut st = obj.clone();
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
}
// A colour written the way the other formatting commands take it, as the model stores it.
let color = |v: &mut Json, what: &str| -> Result<()> {
if v.is_string() || v.get("theme").is_some() {
let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(())
};
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
&& let Some(c) = f.get_mut("color")
{
color(c, "font.color")?;
}
if let Some(fill) = st.get_mut("fill") {
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) {
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
} else if fill.is_string() || fill.get("theme").is_some() {
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
}
if let Some(f) = fill.as_object_mut() {
for k in ["fg", "bg"] {
if let Some(c) = f.get_mut(k) {
color(c, &format!("fill.{k}"))?;
}
}
// A colour with no pattern is a solid fill, not an invisible one.
if f.contains_key("fg") && !f.contains_key("pattern") {
f.insert("pattern".into(), json!("Solid"));
}
}
}
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
for (side, line) in b.iter_mut() {
if let Some(c) = line.get_mut("color") {
color(c, &format!("borders.{side}.color"))?;
}
}
}
Ok(Json::Object(st))
}
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
// only parses after several strips and would still be saved unparseable. (#1999 review)
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
}
Ok(())
}
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
check_cf_formula("home.conditionalFormat", what, f)?;
}
Ok(())
}
fn check_cf_rule(rule: &CfRule) -> Result<()> {
const CMD: &str = "home.conditionalFormat";
match rule {
CfRule::CellIs { a, b, .. } => {
check_cf_formula(CMD, "comparison value", a)?;
if let Some(b) = b {
check_cf_formula(CMD, "second comparison value", b)?;
}
}
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
CfRule::DataBar { min, max, .. } => {
check_cf_value("data bar minimum", min)?;
check_cf_value("data bar maximum", max)?;
}
CfRule::ColorScale { stops } => {
for (kind, _) in stops {
check_cf_value("color scale threshold", kind)?;
}
}
_ => {}
}
Ok(())
}
fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let sheet = target_sheet(s, p)?;
@@ -1343,9 +1527,10 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
};
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
let style = Box::new(match rule.get("style") {
Some(st) if st.is_object() => {
Some(st) if !st.is_null() => {
let st = cf_style_json(st)?;
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
}
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
});
@@ -1384,8 +1569,12 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);
let quote = |v: String| {
// A number, a formula, or text already written as a string literal (`"Closed"`, as
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
v.trim_start_matches('=').to_string()
} else if is_string_literal(&v) {
v
} else {
format!("\"{}\"", v.replace('"', "\"\""))
}
@@ -1442,6 +1631,7 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
},
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
};
check_cf_rule(&rule)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
@@ -1553,4 +1743,50 @@ mod tests {
let rules = s.execute("home.manageRules", json!({})).unwrap();
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
}
#[test]
fn table_without_header_keeps_outside_references() {
// #1030: the data moved down for the new header row, but formulas outside kept the old addresses.
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap();
s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], "Column1");
assert_eq!(get(&mut s, "A2")["value"], json!(10.0));
assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)");
assert_eq!(get(&mut s, "B1")["value"], json!(30.0));
assert_eq!(get(&mut s, "C1")["formula"], "=A3");
assert_eq!(get(&mut s, "C1")["value"], json!(20.0));
let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap();
assert_eq!(other["formula"], "=Sheet1!A3");
assert_eq!(other["value"], json!(20.0));
}
#[test]
fn table_names_refuse_defined_names_and_r1c1() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["h"], ["1"]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap();
s.execute("insert.table", json!({"range": "A1:A2"})).unwrap();
let e = s.execute("table.rename", json!({"table": "Table1", "name": "Rate"})).unwrap_err();
assert!(e.to_string().contains("already exists"), "{e}");
let e = s.execute("table.rename", json!({"table": "Table1", "name": "rate"})).unwrap_err();
assert!(e.to_string().contains("already exists"), "{e}");
for n in ["R2C3", "c", "r", "RC", "C5"] {
let e = s.execute("table.rename", json!({"table": "Table1", "name": n})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{n}: {e}");
let e = s.execute("insert.table", json!({"range": "C1:C2", "name": n})).unwrap_err();
assert!(e.to_string().contains("not valid"), "{n}: {e}");
}
s.execute("table.rename", json!({"table": "Table1", "name": "Table1"})).unwrap();
s.execute("table.rename", json!({"table": "Table1", "name": "Rates"})).unwrap();
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
"text": crate::display::cell_text(&d.wb, sh, c),
"input": sh.input_text(c),
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
"spilledFrom": sh.spill_anchor(c).map(|a| a.a1()),
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
"style": style,
"merge": sh.merge_at(c).map(|m| m.a1()),
+70 -22
View File
@@ -246,6 +246,11 @@ pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
Ok(s.doc()?.selection.ranges.clone())
}
/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
}
pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
}
@@ -269,13 +274,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
Ok(d.wb.active_sheet)
}
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
pub(crate) fn split_sheet(r: &str) -> (Option<String>, &str) {
match r.rsplit_once('!') {
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
None => (None, r),
}
}
pub(crate) use gridcraft_core::split_sheet;
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
@@ -394,6 +393,18 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
Ok(())
}
/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
///
/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
/// permissions are the ones that are not about one cell's contents.
pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&gridcraft_model::SheetProtection) -> bool) -> Result<()> {
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
}
Ok(())
}
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
/// Range without a password or one unlocked in this session, else the message to show.
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
@@ -453,18 +464,53 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C
}
}
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
/// multi-line text. Rows with a user-set height keep it.
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
/// drawn line ends early at the last word that fits. A word wider than the column breaks
/// mid-word, the way egui does. (#1969)
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
let space_w = word_w(" ");
let mut lines = 1.0f32;
let mut used = 0.0f32;
for word in line.split_whitespace() {
let w = word_w(word);
if w > width {
// wider than the column: egui fills the current line, then breaks it across lines
if used > 0.0 {
lines += 1.0;
}
let chunks = (w / width).ceil();
lines += chunks - 1.0;
used = w - (chunks - 1.0) * width;
continue;
}
let gap = if used > 0.0 { space_w } else { 0.0 };
if used + gap + w > width {
lines += 1.0;
used = w;
} else {
used += gap + w;
}
}
lines
}
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
/// Rows with a user-set height keep it.
///
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
/// a multi-line value shows on one line and AutoFit gives a one-line row.
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
let Some(sh) = wb.sheet(si) else { return };
if sh.rows.get(&row).is_some_and(|i| i.custom) {
return;
}
let default = sh.default_row_height;
// Fast path: rows of default-size, unwrapped, single-line content keep the default height.
// Fast path: rows of default-size, unwrapped content keep the default height.
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
let st = wb.styles.get(cell.style);
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n'))
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
});
if plain {
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
@@ -482,22 +528,24 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
}
let mut need: f32 = default;
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
continue;
}
let st = wb.styles.get(cell.style);
let size = st.font.size;
let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE);
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
let mut lines = 1.0f32;
if !cell.value.is_empty() || cell.formula.is_some() {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
let explicit = text.lines().count().max(1) as f32;
lines = if st.align.wrap {
// Only a wrapping cell is laid out over several lines: each explicit line break
// starts one, and each line folds at the column width.
if st.align.wrap {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
let w = sh.col_width(c).max(1.0) - 6.0;
text.lines()
.map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0))
.sum::<f32>()
.max(1.0)
} else {
explicit
};
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
}
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
continue;
}
+57 -2
View File
@@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
Json::Object(_) => {
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
add_to_area("insert.pivotTable", pt, &src, n, area, func_param(str_param(f, "func")), str_param(f, "name"), None)?;
let func = match str_param(f, "func") {
Some(fname) => {
Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?)
}
None => None,
};
add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
let by = group_param(str_param(f, "group"));
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
&& matches!(area, "rows" | "columns")
@@ -1386,7 +1392,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
let (sheet, addr) = match t.rsplit_once('!') {
Some((sh, a)) => {
let sh = sh.trim();
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
let sh = gridcraft_core::unquote_sheet(sh);
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
@@ -2308,4 +2314,53 @@ mod tests {
// 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit.
s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap();
}
#[test]
fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]}))
.unwrap();
assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14");
// Rows above the report (and below the source) move the report only.
s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap();
s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap();
assert_eq!(text(&s, "H12"), "Row Labels");
let l = s.execute("pivot.list", json!({})).unwrap();
assert_eq!(l[0]["anchor"], "H12");
assert_eq!(l[0]["range"], "H12:I16");
assert_eq!(l[0]["source"], "Sheet1!A1:E15");
// A row above the source moves both.
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
let p = pt(&s);
assert_eq!(p.source, "Sheet1!A2:E16");
assert_eq!(p.anchor.a1(), "H13");
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17");
assert_eq!(text(&s, "H13"), "Row Labels");
assert_eq!(val(&s, "I17"), Value::Number(TOTAL));
// Deleting a column left of the report moves it left.
s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap();
let p = pt(&s);
assert_eq!(p.anchor.a1(), "G13");
assert_eq!(p.last_range.unwrap().a1(), "G13:H17");
// Deleting rows above moves it up.
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(pt(&s).source, "Sheet1!A1:E15");
assert_eq!(pt(&s).anchor.a1(), "G12");
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(text(&s, "G12"), "Row Labels");
assert_eq!(val(&s, "H16"), Value::Number(TOTAL));
}
#[test]
fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
let l = s.execute("pivot.list", json!({})).unwrap();
assert_eq!(l[0]["source"], "Sheet1!A3:E15");
assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
}
+231 -21
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Page Setup…",
["Page Layout", "Page Setup"],
None,
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
has_doc,
page_setup
),
@@ -71,19 +71,36 @@ pub fn paper_size(name: &str) -> (f32, f32) {
known_paper(name).unwrap_or((612.0, 792.0))
}
/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its
/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as
/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too.
fn known_paper(name: &str) -> Option<(f32, f32)> {
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");
// Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5`
// are one name.
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], "");
Some(match n.as_str() {
"letter" | "usletter" => (612.0, 792.0),
"legal" | "uslegal" => (612.0, 1008.0),
"a4" => (595.28, 841.89),
"a3" => (841.89, 1190.55),
"a5" => (419.53, 595.28),
"a6" => (297.64, 419.53),
"tabloid" | "11x17" => (792.0, 1224.0),
"ledger" => (1224.0, 792.0),
"executive" => (522.0, 756.0),
"b5" => (498.9, 708.66),
"b4" => (708.66, 1000.63),
"b4" | "b4jis" => (728.50, 1031.81),
"b5" | "b5jis" => (515.91, 728.50),
"b6" | "b6jis" => (362.83, 515.91),
"b4iso" => (708.66, 1000.63),
"b5iso" => (498.90, 708.66),
"b6iso" => (354.33, 498.90),
"japanesepostcard" | "postcard" => (283.46, 419.53),
"japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93),
"japaneseenvelope#3" | "chou#3" => (340.16, 666.14),
// 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out.
"japaneseenvelope#4" | "chou#4" => (255.12, 581.10),
"japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10),
"japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20),
_ => return None,
})
}
@@ -604,20 +621,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option<Rgb> {
c.resolve(&wb.theme)
}
fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb {
use gridcraft_numfmt::FormatColor as F;
match c {
F::Black | F::Indexed(_) => [0, 0, 0],
F::Blue => [0, 0, 255],
F::Cyan => [0, 255, 255],
F::Green => [0, 128, 0],
F::Magenta => [255, 0, 255],
F::Red => [255, 0, 0],
F::White => [255, 255, 255],
F::Yellow => [255, 255, 0],
}
}
fn blend(fg: [u8; 4]) -> Rgb {
let a = fg[3] as u16;
let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8;
@@ -650,6 +653,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option<c
if st.font.underline != Underline::None {
style.font.underline = st.font.underline;
}
// In a differential style General means "not set" (#1163).
if st.num_fmt.as_str() != "General" {
style.num_fmt = st.num_fmt.clone();
}
for (dst, src) in [
(&mut style.borders.left, st.borders.left),
(&mut style.borders.right, st.borders.right),
@@ -996,6 +1003,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
pg.restore();
}
struct PendingPdfLink {
page: usize,
sheet: usize,
rect: (f32, f32, f32, f32),
target: String,
}
struct PrintedBlock {
page: usize,
sheet: usize,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
k: f32,
}
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
/// pages and receives one annotation on each of them.
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
let range = b.range();
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
if !range.contains(*cell) {
continue;
}
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
let (x, y, w, h) = b.rect(cell_range);
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
continue;
}
let rect = (x0, y0, x1 - x0, y1 - y0);
match crate::links::classify(&link.target) {
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
crate::links::LinkKind::Internal(target) if !link.external => {
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
}
_ => {}
}
}
}
fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
let (sheet_name, range) = split_sheet(target);
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
let sh = wb.sheet(sheet)?;
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
Some((block.page, x, y))
}
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
let (x, y, w, h) = rect;
@@ -1061,7 +1124,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
let avail = (w - 2.0 * pad - indent).max(0.0);
let code = st.num_fmt.as_str();
let formatted = crate::display::format(&v, code, wb);
let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) {
color = rgb_of(st.font.color, wb).unwrap_or(color);
}
@@ -1076,7 +1139,8 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
let max_chars = (avail / digit).floor().max(1.0) as usize;
gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255)))
} else {
crate::display::cell_text(wb, sh, c)
// The look's code, which a conditional format can override (#1163).
crate::display::value_text_as(wb, &v, code)
};
if !wrap {
text = text.replace(['\n', '\r'], " ");
@@ -1462,6 +1526,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
let (date, time) = hf_date_time(wb);
let mut page_no = 0usize;
let mut pending_links = Vec::new();
let mut printed_blocks = Vec::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -1476,6 +1542,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
let page_index = doc.page_count();
let pg = doc.add_page(lay.page_w, lay.page_h);
let mut y = oy + lay.heading_h * k;
let mut body: Option<Block> = None;
@@ -1484,6 +1551,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
for (csi, cs) in csegs.iter().enumerate() {
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
draw_block_links(pg, &b, page_index, &mut pending_links);
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
if lay.settings.headings {
draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
}
@@ -1514,6 +1583,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
}
}
for link in pending_links {
if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
&& let Some(page) = doc.page_mut(link.page)
{
let (lx, ly, w, h) = link.rect;
page.link_page(lx, ly, w, h, target_page, x, y);
}
}
let n = doc.page_count();
(doc.finish(), n)
}
@@ -1759,6 +1836,29 @@ mod tests {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
let mut s = s();
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap();
s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap();
s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap();
s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 2);
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("/Subtype /Link").count(), 2);
assert!(text.contains("/URI (https://example.org/report)"));
assert!(text.contains("/Dest [") && text.contains("/XYZ "));
assert!(!text.contains("/URI (file:"));
// A link to a cell omitted by the selected print range must not point to a nonexistent
// destination. The external link in the selected range remains clickable.
let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"}));
let cropped = String::from_utf8_lossy(&cropped);
assert_eq!(cropped.matches("/Subtype /Link").count(), 1);
assert!(!cropped.contains("/Dest ["));
}
#[test]
fn header_sections_split_join_and_field_names() {
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
@@ -1894,6 +1994,16 @@ mod tests {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
#[test]
fn pdf_prints_cjk_text_instead_of_question_marks() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj");
assert!(contains(&pdf, "<7B7E5B576E055355> Tj"));
assert!(contains(&pdf, "(2) Tj"));
}
#[test]
fn pdf_contains_text_and_header_codes_expand() {
let mut s = s();
@@ -1932,6 +2042,31 @@ mod tests {
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
}
#[test]
fn conditional_number_format_prints() {
// #1163: a rule's number format replaces the cell's own, whether that is General or not;
// cells the rule doesn't match keep theirs.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap();
let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}});
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General");
assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule");
assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule");
}
#[test]
fn pdf_uses_indexed_number_format_palette_color() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 1);
assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream");
}
#[test]
fn export_writes_file() {
let mut s = s();
@@ -2000,7 +2135,7 @@ mod tests {
wb.sheet_mut(0).unwrap().images.push(Image {
id,
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
data: png,
data: png.into(),
mime: "image/png".into(),
alt: String::new(),
});
@@ -2041,6 +2176,81 @@ mod tests {
assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some());
}
/// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the
/// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too.
#[test]
fn japanese_paper_sizes_are_jis() {
for (name, w, h) in [
("A4", 595.28, 841.89), // unchanged
("B4", 728.50, 1031.81),
("B5", 515.91, 728.50),
("B6", 362.83, 515.91),
("B4 (JIS)", 728.50, 1031.81),
("b5-jis", 515.91, 728.50),
("B4 (ISO)", 708.66, 1000.63),
("B5 (ISO)", 498.90, 708.66),
("B6 (ISO)", 354.33, 498.90),
("A6", 297.64, 419.53),
("Japanese Postcard", 283.46, 419.53),
("japanese double postcard", 419.53, 566.93),
("Japanese Envelope #3", 340.16, 666.14),
("chou #4", 255.12, 581.10),
("Japanese Envelope Kaku #2", 680.31, 941.10),
("kaku #3", 612.28, 785.20),
] {
let got = paper_size(name);
assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}");
}
// 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the
// 225 mm it once had.
let chou4 = paper_size("Japanese Envelope #4");
assert_eq!((chou4.0, chou4.1), (255.12, 581.10));
assert_eq!(chou4, paper_size("chou #4"));
// Unknown names keep the old Letter fallback.
assert_eq!(paper_size("Napkin"), (612.0, 792.0));
}
/// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is
/// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and
/// fit-to-page keeps the paper size.
#[test]
fn page_setup_paper_sizes_reach_the_pdf_page() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap();
let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01;
for (paper, w, h) in [
("A4", 595.28f64, 841.89f64),
("B5", 515.91, 728.50),
("Japanese Postcard", 283.46, 419.53),
("Japanese Double Postcard", 419.53, 566.93),
("Japanese Envelope Kaku #2", 680.31, 941.10),
] {
s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap();
let pv = s.execute("file.printPreview", json!({})).unwrap();
let page = &pv["pageList"][0];
assert_eq!(page["paper"], paper);
assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}");
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 1);
let want = format!("/MediaBox [0 0 {w} {h}]");
assert!(contains(&pdf, &want), "{paper}: wanted {want}");
}
// Landscape swaps the sides, and fit-to-page does not change the paper.
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 1);
assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]"));
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true}));
assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]"));
// `pageLayout.size` still takes any name, and an unknown one prints on Letter as before.
s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap();
let pv = s.execute("file.printPreview", json!({})).unwrap();
assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0));
// Page Setup refuses an unknown name.
assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err());
}
#[test]
fn hostile_settings_never_panic_and_cap_pages() {
let mut s = s();
+52 -2
View File
@@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
}
let b = |k: &str| bool_param(p, k).unwrap_or(false);
// Selection rights default to what the sheet has now (a file may forbid selecting locked cells).
let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone());
let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked));
let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked));
let prot = SheetProtection {
password: None,
legacy_password_hash: None,
select_locked: true,
select_unlocked: true,
select_locked,
select_unlocked,
format_cells: b("formatCells"),
format_columns: b("formatColumns"),
format_rows: b("formatRows"),
@@ -399,6 +403,26 @@ mod tests {
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
let mut s = book();
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
// Locked cells can't be selected; unlocked ones can.
assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
// Arrow keys skip locked cells, and stay put when nothing selectable is left.
s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
// Re-protecting without the flag keeps the sheet's selection rights.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked);
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
}
#[test]
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
let mut s = book();
@@ -426,6 +450,32 @@ mod tests {
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
}
#[test]
fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() {
let mut s = book();
let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let before = names(&s);
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err());
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err());
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err());
assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err());
assert_eq!(names(&s), before);
assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible));
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
s.execute("sheet.hide", json!({"sheet": 1})).unwrap();
s.execute("review.protectWorkbook", json!({})).unwrap();
assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err());
assert!(s.execute("sheet.unhide", json!({})).is_err());
assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden);
s.execute("review.protectWorkbook", json!({})).unwrap();
s.execute("sheet.unhide", json!({"sheet": 1})).unwrap();
}
#[test]
fn protect_workbook_with_a_password_really_verifies() {
let mut s = book();
+60 -15
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
let key = if axis == Axis::Rows { "rows" } else { "cols" };
let r = match str_param(p, key).or_else(|| str_param(p, "range")) {
Some(t) => RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
Some(t) => parse_lines(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
None => s.doc()?.selection.current(),
};
let (a, b) = if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) };
@@ -211,10 +211,19 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
if let Some(pa) = sh.print.print_area {
sh.print.print_area = map_range(pa);
}
// PivotTables follow their report cells. A deleted anchor line collapses onto the line that
// takes its place; a report deleted entirely has no last area until the next refresh.
for pt in sh.pivots.iter_mut() {
pt.anchor = map_cell(pt.anchor).unwrap_or_else(|| match axis {
Axis::Rows => CellRef::new(at, pt.anchor.col),
Axis::Cols => CellRef::new(pt.anchor.row, at),
});
pt.last_range = pt.last_range.and_then(map_range);
}
}
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
super::edit::rewrite_all_formulas(wb, target, e);
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -226,6 +235,8 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
}
};
for ch in sh.charts.iter_mut() {
// The source block moves with its cells; one that was deleted is dropped.
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
for se in ch.series.iter_mut() {
se.values = fix(&se.values);
se.categories = se.categories.as_deref().map(fix);
@@ -235,6 +246,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
for sp in sh.sparklines.iter_mut() {
sp.source = fix(&sp.source);
}
// PivotTable sources (a range, table or defined name) are only rewritten when the edit
// moves them, so table and name sources keep their text.
for pt in sh.pivots.iter_mut() {
let body = pt.source.trim().trim_start_matches('=');
if let Ok(expr) = gridcraft_formula::parse(body) {
let before = gridcraft_formula::print(&expr);
let after = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, &host, target, e));
if after != before {
pt.source = after;
}
}
}
}
}
@@ -306,7 +329,7 @@ fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
t.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<_>>()?
@@ -469,6 +492,15 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
check_table_shift(sh, &e)?;
// Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped.
let doomed = if shift == "right" {
RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1))
} else {
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()));
}
}
let right = shift == "right";
let out = edit(s, |cx| {
@@ -746,7 +778,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
*sc -= 1;
}
// Names referring to the sheet become #REF!.
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order);
if ne != e {
n.formula = gridcraft_formula::print(&ne);
@@ -767,6 +799,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let i = target_sheet(s, p)?;
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { return Err(bad("sheet.rename", "missing `name`")) };
s.doc()?.wb.check_sheet_name(&name, Some(i)).map_err(EngineError::Other)?;
@@ -808,7 +843,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
for n in cx.wb.names.iter_mut() {
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
}
}
@@ -818,6 +853,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let i = target_sheet(s, p)?;
let copy = bool_param(p, "copy").unwrap_or(false);
let to = p.get("to").and_then(Json::as_u64).map(|v| v as usize).ok_or_else(|| bad("sheet.move", "missing `to`"))?;
@@ -847,7 +885,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
.iter()
.filter(|nm| nm.scope == Some(i))
.map(|nm| {
let formula = gridcraft_formula::parse(&nm.formula)
let formula = gridcraft_formula::parse_name(&nm.formula)
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name)))
.unwrap_or_else(|_| nm.formula.clone());
gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() }
@@ -895,6 +933,9 @@ fn tab_color(s: &mut Session, p: &Json) -> Result<Json> {
}
fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let i = target_sheet(s, p)?;
if s.doc()?.wb.sheets.iter().filter(|s| s.visibility == Visibility::Visible).count() <= 1 {
return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into()));
@@ -909,6 +950,9 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
if p.get("sheet").is_none() {
let hidden: Vec<String> = s.doc()?.wb.sheets.iter().filter(|s| s.visibility != Visibility::Visible).map(|s| s.name.clone()).collect();
if hidden.is_empty() {
@@ -932,17 +976,18 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"sheet": i, "name": d.wb.active().map(|s| s.name.clone())}))
}
fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
let d = s.doc()?;
let n = d.wb.sheets.len() as i64;
let mut i = d.wb.active_sheet as i64;
for _ in 0..n {
i = (i + dir).clamp(0, n - 1);
if d.wb.sheet(i as usize).is_some_and(|s| s.visibility == Visibility::Visible) {
break;
let mut i = d.wb.active_sheet;
while let Some(next) = i.checked_add_signed(dir) {
let Some(sheet) = d.wb.sheet(next) else { break };
if sheet.visibility == Visibility::Visible {
s.doc_mut()?.wb_switch_sheet(next);
return ok();
}
i = next;
}
s.doc_mut()?.wb_switch_sheet(i as usize);
// No visible sheet in this direction: keep the current sheet and its selection.
ok()
}
@@ -963,7 +1008,7 @@ pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
for cf in sh.cond_formats.iter_mut() {
+14 -4
View File
@@ -97,11 +97,21 @@ fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) {
}
fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
// A table named by `table` is found on any sheet, as the other table commands find it, and the slicer goes on that
// table's sheet. Looked up on the active sheet only, a named table on another sheet gave "Select a cell inside a
// table first, or pass `table`" although `table` was passed.
let named = match str_param(p, "table") {
Some(name) => Some(s.doc()?.wb.table(name).ok_or_else(|| bad("insert.slicer", format!("no table named `{name}`")))?),
None => None,
};
let sheet = match named {
Some((si, _)) => si,
None => target_sheet(s, p)?,
};
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let t = match str_param(p, "table") {
Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)),
let t = match named {
Some((_, ti)) => sh.tables.get(ti),
None => sh.table_at(d.selection.active),
}
.ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?;
@@ -119,7 +129,7 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
}
};
let table = t.name.clone();
let at = cell_param(s, p, "at")?
let at = cell_param_on(s, p, "at", sheet)?
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10);
let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32;
+201 -56
View File
@@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
Ok(out)
}
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
/// the model's sheet stays active. Returns the new sheets' names.
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
/// Builds every report before the solution or saved model can change.
fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
if kinds.is_empty() {
return Ok(Vec::new());
}
let d = s.doc()?;
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
@@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
built.push((name, lines));
}
Ok(built)
}
/// Inserts prepared reports before the model's sheet, which stays active.
fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<String> {
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
let at = inp.model.sheet;
let count = built.len();
edit(s, |cx| {
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
for (i, (name, lines)) in built.into_iter().enumerate() {
let mut sheet = Sheet::new(name);
for (r, c, v, b) in lines {
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
if count == 0 {
return names;
}
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
for (i, (name, lines)) in built.into_iter().enumerate() {
let mut sheet = Sheet::new(name);
for (r, c, v, b) in lines {
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
}
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += count;
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
}
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += count;
}
cx.structural = true;
Ok(())
})?;
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
}
cx.structural = true;
names
}
fn insert_report_selections(s: &mut Session, at: usize, count: usize) {
if let Ok(d) = s.doc_mut() {
for i in 0..count {
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
}
}
Ok(names)
}
/// `data.solverReport`: reports for a solution of the sheet's saved model.
@@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
let sheets = write_reports(s, &kinds, &inp)?;
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
insert_report_selections(s, model.sheet, sheets.len());
Ok(json!({"sheets": sheets}))
}
@@ -2068,41 +2075,53 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
}
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
let changed = keep && solution.is_some();
if changed || save {
let sheet = model.sheet;
let vars = model.vars.clone();
let m = model.clone();
edit(s, |cx| {
if changed && let Some(x) = &solution {
let sh = cx.sheet_mut(sheet)?;
for (c, v) in vars.iter().zip(x) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
}
for c in &vars {
cx.touch(sheet, *c);
}
}
if save {
save_model(&mut cx.wb, &m);
}
Ok(())
})?;
}
let built = match (&reports, &solution) {
(Some(kinds), Some(x)) => {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
prepare_reports(s.doc()?, kinds, &inp)?
}
_ => Vec::new(),
};
let count = built.len();
let before = s.doc()?.wb.clone();
let mut out = json!({
"status": status,
"message": message,
"objective": objective,
"values": values,
"original": original,
"model": model_params(&s.doc()?.wb, &model),
"model": model_params(&before, &model),
"evaluations": evaluations,
"changed": changed,
});
// Reports describe a solution; they are part of the solve's undo step.
if let (Some(kinds), Some(x)) = (reports, solution) {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
let names = if changed || save || count > 0 {
let sheet = model.sheet;
edit(s, |cx| {
if changed && let Some(x) = &solution {
let sh = cx.sheet_mut(sheet)?;
for (c, v) in model.vars.iter().zip(x) {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
}
for c in &model.vars {
cx.touch(sheet + count, *c);
}
}
if save {
save_model(&mut cx.wb, &model);
}
if count > 0 {
// Inserting sheets makes this structural, so retain the variable edit's array check.
check_arrays(&before, &cx.wb)?;
}
Ok(write_reports(cx, sheet, built))
})?
} else {
Vec::new()
};
insert_report_selections(s, model.sheet, count);
if reports.is_some() && solution.is_some() {
out["reports"] = json!(names);
}
Ok(out)
}
@@ -2390,6 +2409,132 @@ mod tests {
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
}
#[test]
fn refused_solver_reports_preserve_the_document_and_history() {
for (protected, method, constraints, reports, error) in [
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
] {
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
s.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}),
)
.unwrap();
set(&mut s, &[("C1", "redo")]);
s.execute("edit.undo", json!({})).unwrap();
let d = s.doc_mut().unwrap();
std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected;
d.saved = d.wb.clone();
let before = d.clone();
let journal = s.journal.clone();
let err = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method,
"reports": reports, "keep": keep, "saveModel": save}),
)
.unwrap_err();
assert!(err.to_string().contains(error), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}");
assert!(!d.is_dirty());
assert_eq!(d.revision, before.revision);
assert_eq!(d.selection, before.selection);
assert_eq!(d.sheet_selections, before.sheet_selections);
for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] {
assert_eq!(after.len(), before.len());
for (a, b) in after.iter().zip(before) {
assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb));
assert_eq!((&a.label, &a.selection), (&b.label, &b.selection));
}
}
assert_eq!(s.journal, journal);
}
}
}
#[test]
fn solver_reports_and_optional_solution_and_model_share_one_undo() {
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
let before = s.doc().unwrap().clone();
let out = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex",
"reports": ["answer"], "keep": keep, "saveModel": save}),
)
.unwrap();
assert_eq!(out["status"], "optimal");
assert_eq!(out["reports"], json!(["Answer Report 1"]));
assert_eq!(out["model"]["objective"], "Sheet1!B1");
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
assert_eq!(saved(&s), if save { "solver_opt" } else { "" });
assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1);
assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1");
let r = row_of(&s, "Answer Report 1", "$A$1") + 1;
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0));
s.execute("edit.undo", json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
}
}
#[test]
fn solver_reports_keep_row_autofit_on_the_model_sheet() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap();
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().row_height(0);
s.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}),
)
.unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.name, "Sheet1");
assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0));
assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before);
}
#[test]
fn solver_report_insertion_keeps_variable_edit_guards() {
for array in [false, true] {
let mut s = s();
set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]);
let error = if array {
s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
"part of an array"
} else {
s.execute("review.protectSheet", json!({})).unwrap();
"protected sheet"
};
let before = s.doc().unwrap().clone();
let err = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}),
)
.unwrap_err();
assert!(err.to_string().contains(error), "{err}");
let d = s.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
assert_eq!(d.undo.len(), before.undo.len());
assert_eq!(d.sheet_selections, before.sheet_selections);
}
}
#[test]
fn limits_report_of_a_nonlinear_model() {
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
+68 -15
View File
@@ -13,19 +13,30 @@ pub fn specs() -> Vec<CommandSpec> {
"Freeze Panes",
["View", "Window", "Freeze Panes"],
None,
"{cell?: \"B2\" (rows above and columns left of it freeze)}",
"{sheet?, cell?: \"B2\" (rows above and columns left of it freeze); without `cell`, unfreezes a frozen sheet}",
has_doc,
freeze_panes
),
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, Some((1, 0)))),
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
s,
target_sheet(s, p)?,
Some((1, 0))
)),
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
s,
target_sheet(s, p)?,
Some((0, 1))
)),
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, None)),
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
s,
target_sheet(s, p)?,
None
)),
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
@@ -35,7 +46,15 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
cmd!(
"pageLayout.size",
"Size",
["Page Layout", "Page Setup"],
None,
"{paper: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes)}",
has_doc,
paper
),
cmd!(
"pageLayout.margins",
"Margins",
@@ -113,23 +132,28 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
if sh.freeze.is_some() && p.get("cell").is_none() {
return set_freeze(s, None);
return set_freeze(s, si, None);
}
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let at = match cell_param_on(s, p, "cell", si)? {
Some(c) => c,
None if si == d.wb.active_sheet => d.selection.active,
// The selection belongs to the active sheet.
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
};
let top = sh.view_top_left;
let rows = at.row.saturating_sub(top.row);
let cols = at.col.saturating_sub(top.col);
let _ = (rows, cols);
let (rows, cols) = (at.row, at.col);
set_freeze(s, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
set_freeze(s, si, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
}
fn set_freeze(s: &mut Session, f: Option<(u32, u32)>) -> Result<Json> {
fn set_freeze(s: &mut Session, si: usize, f: Option<(u32, u32)>) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = f;
Ok(json!({"freeze": f}))
})
@@ -143,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"gridlines" => &mut sh.show_gridlines,
"headings" => &mut sh.show_headings,
"zeros" => &mut sh.show_zeros,
"outlineSymbols" => &mut sh.show_outline_symbols,
"rtl" => &mut sh.right_to_left,
"printGridlines" => &mut sh.print.gridlines,
_ => &mut sh.print.headings,
@@ -152,6 +177,16 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
})
}
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
use gridcraft_model::ShowObjects;
edit(s, |cx| {
let on = bool_param(p, "on").unwrap_or(cx.wb.show_objects == ShowObjects::None);
cx.wb.show_objects = if on { ShowObjects::All } else { ShowObjects::None };
Ok(json!({"on": on}))
})
}
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
let d = s.doc_mut()?;
@@ -232,13 +267,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
}
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row));
let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col));
// An absent key leaves that axis alone, and an empty or null value clears it — the same
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
// key that was never sent used to wipe the other axis.
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
match p.get(key) {
None => Ok(None),
Some(Json::Null) => Ok(Some(None)),
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
Some(Json::String(t)) => match RangeRef::parse(t) {
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
},
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
}
};
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
pr.title_rows = rows;
pr.title_cols = cols;
if let Some(t) = rows {
pr.title_rows = t;
}
if let Some(t) = cols {
pr.title_cols = t;
}
Ok(Json::Null)
})
}
+104 -34
View File
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Advanced…",
["Data", "Sort & Filter"],
None,
"{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
has_doc,
advanced_filter
),
@@ -488,8 +488,15 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
edit(s, |cx| {
for (c, v) in cells.iter().zip(sc.values.iter()) {
let input = super::edit::json_to_input(v);
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
// Stored numbers are values, not typed text: entering them through the parser would
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
let cell = match v.as_f64() {
Some(n) => {
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
Some(Cell { value: Value::Number(n), formula: None, style })
}
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
};
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(*c, cell),
@@ -795,41 +802,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
}
};
let (csi, crit) = range_arg(s, p, "criteria", CMD)?;
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
if list.height() < 1 || list.count() > 50_000_000 {
return Err(bad(CMD, "the list range is too large"));
}
if crit.width() > 1024 || crit.height() > 10_000 {
if let Some((_, crit)) = criteria
&& (crit.width() > 1024 || crit.height() > 10_000)
{
return Err(bad(CMD, "the criteria range is too large"));
}
let wb = &d.wb;
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
let headers: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
let first_data = list.start.row + 1;
// Criteria rows → conditions.
let mut rows: Vec<Vec<Cond>> = Vec::new();
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
if let Some((csi, crit)) = criteria {
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
}
}
rows.push(conds);
}
rows.push(conds);
}
let matches_row = |row: u32| -> bool {
if rows.is_empty() {
@@ -845,7 +856,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
}
Cond::Computed(e, at) => {
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted);
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
matches!(v.scalar().to_bool(), Ok(true))
}
})
@@ -867,28 +878,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(t) = str_param(p, "copyTo") {
let (dsi, dest) = parse_range(wb, t, CMD)?;
let dest = dest.start;
let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1));
if dsi == si && extract.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let mut rows_out: Vec<u32> = vec![list.start.row];
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
let end =
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
let out_range = RangeRef::new(dest, end);
if dsi == si && out_range.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let cells: Vec<(CellRef, Cell)> = rows_out
.iter()
.enumerate()
.flat_map(|(i, row)| {
(list.start.col..=list.end.col).filter_map(move |c| {
(list.start.col..=list.end.col).map(move |c| {
let src = CellRef::new(*row, c);
let to = dest.offset(i as i64, (c - list.start.col) as i64)?;
// The full output footprint was checked above.
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
let style = sh.style_id(src);
Some((to, Cell { value: sh.value(src), formula: None, style }))
(to, Cell { value: sh.value(src), formula: None, style })
})
})
.collect();
let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1));
edit(s, |cx| {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
for c in &old {
shm.remove_cell(*c);
}
@@ -1199,6 +1212,17 @@ mod tests {
assert!(!r.converged);
}
#[test]
fn scenario_show_restores_a_percentage_cells_stored_number() {
let mut s = s();
s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap();
s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap();
assert_eq!(n(&s, "B2"), 0.05);
}
#[test]
fn scenarios() {
let mut s = s();
@@ -1317,6 +1341,52 @@ mod tests {
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err());
}
#[test]
fn advanced_filter_copy_preserves_cells_beyond_result() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap();
assert_eq!(r["range"], "K1:L2");
assert_eq!(v(&s, "K2"), Value::from("B"));
assert_eq!(v(&s, "K4"), Value::from("Keep me"));
}
#[test]
fn advanced_filter_copy_rejects_overflow_without_mutation() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
for dest in ["XFD1", "K1048576"] {
s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap();
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err());
assert_eq!(v(&s, dest), Value::from("Keep me"));
}
}
#[test]
fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() {
let mut s = s();
s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap();
assert_eq!(r["range"], "B1:C2");
assert_eq!(v(&s, "C4"), Value::from("Name"));
assert_eq!(v(&s, "C2"), Value::Number(1.0));
}
#[test]
fn advanced_filter_unique_without_criteria() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap();
assert_eq!(r["matched"], 2);
assert_eq!(r["range"], "C1:C3");
assert_eq!(v(&s, "C2"), Value::from("A"));
assert_eq!(v(&s, "C3"), Value::from("B"));
}
#[test]
fn criteria_semantics() {
use super::criterion_matches as m;
+33 -1
View File
@@ -33,6 +33,25 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
format_value(v, &parsed(code), wb.date_system)
}
/// Whether typing `t` back into a General cell would not give the text `t` again (it would parse
/// as a number, boolean, error, date or formula, or lose a leading apostrophe). Such text is shown
/// for editing with a leading `'`, as Excel does for text carrying a quote prefix, so that
/// committing the editor unchanged keeps the cell as text.
fn text_needs_quote_prefix(t: &str, sys: gridcraft_core::date::DateSystem) -> bool {
if t.is_empty() {
return false;
}
let mut chars = t.chars();
let (first, second) = (chars.next(), chars.next());
if matches!(first, Some('=' | '\'')) {
return true;
}
if matches!(first, Some('+' | '-')) && second.is_some_and(|c| c.is_ascii_alphabetic() || c == '(') {
return true;
}
!matches!(gridcraft_core::parse::parse_input(t, sys).value, Value::Text(ref x) if x.as_ref() == t)
}
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
/// serial. When the formatted text would not read back as the same date (for example
@@ -41,6 +60,13 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let raw = sheet.input_text(c);
let Some(cell) = sheet.cell(c) else { return raw };
if let Value::Text(t) = &cell.value
&& cell.formula.is_none()
&& wb.styles.get(cell.style).num_fmt.as_str() != "@"
&& text_needs_quote_prefix(t, wb.date_system)
{
return format!("'{raw}");
}
let Value::Number(n) = &cell.value else { return raw };
let code = wb.styles.get(cell.style).num_fmt.as_str();
if cell.formula.is_some() || !parsed(code).is_date() {
@@ -95,7 +121,13 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result.
pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let style = wb.styles.get(sheet.style_id(c));
let f = format(&sheet.value(c), style.num_fmt.as_str(), wb);
value_text_as(wb, &sheet.value(c), style.num_fmt.as_str())
}
/// [`value_text`] for a value and a format code given apart from a cell (print takes the code
/// from the cell's effective look, which a conditional format can override).
pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String {
let f = format(v, code, wb);
match f.fill {
Some((_, pos)) => {
let mut t = f.text;
+118 -13
View File
@@ -19,7 +19,17 @@ pub enum FillMode {
Alternate,
Copy,
Formats,
/// Fill Without Formatting: extends like a plain drag, the targets keep their formats.
ValuesOnly,
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
Extend,
}
impl FillMode {
/// The modes that continue the source's series rather than copy it.
fn extends_series(self) -> bool {
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
}
}
/// Built-in custom lists.
@@ -35,6 +45,18 @@ fn lists() -> Vec<Vec<String>> {
]
}
/// The most cells one fill may write: the size cap the fill commands put on their ranges.
pub const MAX_FILL_CELLS: u64 = 5_000_000;
/// Whether `target` is a shape Excel's AutoFill accepts for `src`: it contains the source and the
/// source sits in one of its corners. Excel refuses a target that doesn't contain the source
/// (`2:3` into `A:A`, `H4:H9` into `D:F`) or holds it away from every corner (`H4:H9` into `H:H`).
pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
target.contains_range(&src)
&& (target.start.row == src.start.row || target.end.row == src.end.row)
&& (target.start.col == src.start.col || target.end.col == src.end.col)
}
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
let custom = cx.wb.custom_lists.clone();
@@ -46,6 +68,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
} else {
target.end.col > src.end.col || target.start.col == src.start.col
};
// Every source lane writes one cell per step past the source: bound that product, not the
// source and target separately (whole rows into whole columns was ~17 billion writes).
let (lane_count, steps) = if vertical {
let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };
(src.width(), steps)
} else {
let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };
(src.height(), steps)
};
if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS {
return Err(crate::EngineError::Other("The fill is too large.".into()));
}
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
let src_len = if vertical { src.height() } else { src.width() };
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
@@ -67,15 +101,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
match (mode, detected) {
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
(_, p) => p,
}
} else {
Pattern::Copy
};
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
// own series, one step per repeat of the source.
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
let per_cell: Vec<Pattern> = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
} else {
Vec::new()
@@ -93,7 +130,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
@@ -165,6 +202,7 @@ enum Pattern {
last: f64,
first: f64,
months: i64,
sys: gridcraft_core::DateSystem,
},
}
@@ -199,7 +237,7 @@ impl Pattern {
&& (a.month != b.month || a.year != b.year)
{
let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64;
return Pattern::Months { last, first, months };
return Pattern::Months { last, first, months, sys };
}
}
// Least-squares step for 2+ values (Excel uses a linear trend).
@@ -273,12 +311,15 @@ impl Pattern {
}
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
let base = if forward { *last } else { *first };
let n = base + step * k * sign;
// Text numbers come from input and can sit near i64::MAX (`X 9223372036854775807`):
// an overflowing series yields nothing — the cell keeps its source value — rather
// than wrapping in release or panicking in debug.
let n = step.checked_mul(k)?.checked_mul(sign)?.checked_add(base)?;
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
let quarter = suffix.is_empty()
&& (1..=4).contains(&base)
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
let n = if quarter { n.checked_sub(1)?.rem_euclid(4) + 1 } else { n.checked_abs()? };
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
}
Pattern::List { list, last, first, step, upper, lower } => {
@@ -297,9 +338,9 @@ impl Pattern {
s
}))
}
Pattern::Months { last, first, months } => {
Pattern::Months { last, first, months, sys } => {
let base = if forward { *last } else { *first };
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
}
@@ -442,13 +483,13 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
// Header row: skip a first row whose target is text and source looks like a header? Keep simple.
let mut examples = Vec::new();
let mut todo = Vec::new();
// Flash Fill works on the text cells show (a date as formatted, not its serial number).
for r in top..=bottom {
let src = sh.value(CellRef::new(r, src_col)).display();
let dst = sh.value(CellRef::new(r, at.col));
if dst.is_empty() {
let src = crate::display::cell_text(&cx.wb, sh, CellRef::new(r, src_col));
if sh.value(CellRef::new(r, at.col)).is_empty() {
todo.push((r, src));
} else {
examples.push((src, dst.display()));
examples.push((src, crate::display::cell_text(&cx.wb, sh, CellRef::new(r, at.col))));
}
}
if examples.is_empty() {
@@ -603,6 +644,18 @@ fn synthesize(src: &str, dst: &str) -> Vec<Program> {
mod tests {
use super::*;
#[test]
fn flash_fill_reads_shown_text() {
let mut s = crate::Session::new();
s.new_workbook();
let values = serde_json::json!([["2024-01-15", "15/01/2024"], ["2023-12-03", null]]);
s.execute("range.setValues", serde_json::json!({"range": "A1", "values": values})).unwrap();
s.execute("selection.set", serde_json::json!({"cell": "B2"})).unwrap();
s.execute("data.flashFill", serde_json::json!({})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
}
#[test]
fn flash_programs() {
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
@@ -652,4 +705,56 @@ mod tests {
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
}
#[test]
fn text_series_near_i64_max_neither_wraps_nor_panics() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "X 0"})).unwrap();
s.execute("cell.set", serde_json::json!({"cell": "A2", "input": "X 9223372036854775807"})).unwrap();
// The detected step is i64::MAX, so the next value overflows i64. The cell keeps its
// source value instead of panicking in debug or wrapping in release.
s.execute("edit.autoFill", serde_json::json!({"source": "A1:A2", "target": "A1:A3"})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::new(2, 0)), "X 0");
}
#[test]
fn autofill_refuses_targets_that_do_not_extend_the_source() {
// Whole rows into whole columns once filled every source column down the whole sheet
// (~17 billion writes) and aborted the process on a 24-51 GB allocation (fuzz seeds 1064,
// 1114, 1775). Excel refuses every one of these shapes.
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", serde_json::json!({"range": "A2", "values": [[1, "Item 1"], [2, "Item 2"]]})).unwrap();
s.execute("range.setValues", serde_json::json!({"range": "H4", "values": [[10], [20]]})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().cells.len();
for (mode, source, target) in [
("series", "2:3", "A:A"),
("alternate", "2:3", "A:A"),
("series", "8:10", "C:E"),
("copy", "24:24", "B:D"),
("values", "H4:H9", "D:F"),
("series", "H4:H9", "H:H"),
("series", "A2:A3", "A1:A5"),
("series", "A2:A6", "A2:A4"),
("series", "B2:B3", "A2:A3"),
] {
let r = s.execute("edit.autoFill", serde_json::json!({"mode": mode, "source": source, "target": target}));
assert!(r.is_err(), "{source} -> {target} should be refused");
}
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.len(), before, "a refused fill writes nothing");
// Targets holding the source in a corner still fill, whole rows and diagonal drags included.
s.execute("edit.autoFill", serde_json::json!({"source": "2:3", "target": "2:6"})).unwrap();
s.execute("edit.autoFill", serde_json::json!({"source": "A2:A3", "target": "A1:A3"})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let text = |a: &str| crate::display::cell_text(&s.doc().unwrap().wb, sh, CellRef::parse(a).unwrap());
assert_eq!([text("A1"), text("A6"), text("B6")], ["0", "5", "Item 5"]);
assert!(holds_source_in_corner(RangeRef::parse("B5").unwrap(), RangeRef::parse("B3:B5").unwrap()));
assert!(holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("A:C").unwrap()));
assert!(holds_source_in_corner(RangeRef::parse("B5:C9").unwrap(), RangeRef::parse("B5:D13").unwrap()));
assert!(holds_source_in_corner(RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("A1:B4").unwrap()));
assert!(!holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("1:1").unwrap()));
}
}
+481 -30
View File
@@ -55,8 +55,15 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never
/// quote the password.
pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec<String>)> {
open_bytes_with(name, bytes, password, false)
}
/// [`open_bytes_with_password`], and with `csv_as_text` a CSV/TSV/TXT file keeps every field as
/// the text it holds, with no number, date or boolean conversion (identifiers such as GTINs, SKUs
/// and ZIP codes survive). It does not apply to other formats.
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>, csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted {
let (mut wb, warnings) = open_plain(name, bytes)?;
let (mut wb, warnings) = open_plain(name, bytes, csv_as_text)?;
fit_imported_rows(&mut wb);
return Ok((wb, warnings));
}
@@ -70,7 +77,7 @@ pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>
if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
}
let (mut wb, mut warnings) = open_plain(name, &plain)?;
let (mut wb, mut warnings) = open_plain(name, &plain, csv_as_text)?;
fit_imported_rows(&mut wb);
warnings.insert(0, DECRYPTED_NOTICE.into());
Ok((wb, warnings))
@@ -112,7 +119,7 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
}
/// Parses bytes that aren't encrypted.
fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Xls {
@@ -140,7 +147,12 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
}
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
_ => format!("We can't open '{name}': {e}"),
})
})?;
return Ok((wb, report.warnings));
}
match kind {
@@ -150,10 +162,17 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
if wb.sheets.is_empty() {
wb = Workbook::new();
}
Ok((wb, vec![]))
// The file's band placement and cell count are derived state: rebuild them from the
// cells themselves, dropping what is outside the sheet grid (#1008).
let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum();
let mut warnings = Vec::new();
if dropped > 0 {
warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped"));
}
Ok((wb, warnings))
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() };
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
@@ -161,7 +180,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
Ok((wb, vec![]))
}
_ => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, ..Default::default() };
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
@@ -169,6 +188,25 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
}
}
/// What to tell the user when one part of an XLSX package unpacks to more than the reader's
/// per-part limit: the sizes, why the limit is there, and what gets the data in. The limit is
/// per part, so smaller sheets fit; the shared strings are one part for every sheet, so only a
/// CSV, which has no parts, helps there.
fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
let limit = gridcraft_xlsx::MAX_PART >> 20;
let mb = size >> 20;
let size = if mb > limit { format!("{mb} MB") } else { format!("more than {limit} MB") };
let remedy = if part.contains("worksheets/") {
"To open it, split the data across several sheets, since the limit applies to each part, or save the sheet as CSV, which keeps only its values."
} else {
"To open it, save the data as CSV, which keeps only its values."
};
format!(
"We can't open '{name}': its part {part} unpacks to {size}, over GridCraft's limit of {limit} MB for one part \
(a guard against damaged or malicious files). {remedy}"
)
}
/// A CSV opens as a sheet named after the file (like Excel).
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
@@ -200,7 +238,10 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xlsx => {
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
@@ -228,6 +269,12 @@ pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
}
/// The file at `path` as `CELL("filename")` reports it: absolute, so the folder is the one the
/// user sees. A workbook that arrived as bytes has no path and keeps only its name.
pub fn source_path(path: &str) -> Option<std::path::PathBuf> {
std::path::absolute(path).ok()
}
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
/// transferred to other file formats*): number formats applied, never column-width `#####`.
@@ -247,11 +294,11 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
.map_err(|e| EngineError::Other(e.to_string()))
}
fn esc(s: &str) -> String {
pub(crate) fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
let st = wb.styles.get(sh.style_id(c));
let mut css = String::new();
if st.font.bold {
@@ -270,16 +317,59 @@ fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
}
let v = sh.value(c);
let align = match st.align.h {
gridcraft_model::HAlign::Center => "center",
gridcraft_model::HAlign::Right => "right",
gridcraft_model::HAlign::Left => "left",
_ if v.is_number() => "right",
_ => "left",
gridcraft_model::HAlign::Center => Some("center"),
gridcraft_model::HAlign::Right => Some("right"),
gridcraft_model::HAlign::Left => Some("left"),
_ if v.is_number() => Some(if sh.right_to_left { "left" } else { "right" }),
_ if dir == CellDir::Auto => None,
_ => Some("left"),
};
let _ = write!(css, "text-align:{align};");
if let Some(align) = align {
let _ = write!(css, "text-align:{align};");
}
css
}
/// How a cell marks its direction in HTML. Text on a right-to-left sheet, or text with
/// right-to-left letters, follows its first strong character (`dir="auto"`), so General
/// alignment puts Arabic and Hebrew on the right. Numbers on a right-to-left sheet stay left to
/// right so `-5` doesn't read `5-`.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CellDir {
Inherit,
Auto,
Ltr,
}
impl CellDir {
fn of(sh: &Sheet, c: CellRef, text: &str) -> CellDir {
if sh.value(c).is_number() {
if sh.right_to_left { CellDir::Ltr } else { CellDir::Inherit }
} else if sh.right_to_left || text.chars().any(is_right_to_left_letter) {
CellDir::Auto
} else {
CellDir::Inherit
}
}
fn attr(self) -> &'static str {
match self {
CellDir::Inherit => "",
CellDir::Auto => " dir=\"auto\"",
CellDir::Ltr => " dir=\"ltr\"",
}
}
}
/// Hebrew, Arabic, Syriac, Thaana, N'Ko and their presentation forms.
fn is_right_to_left_letter(c: char) -> bool {
matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')
}
fn table_dir(sh: &Sheet) -> &'static str {
if sh.right_to_left { " dir=\"rtl\"" } else { "" }
}
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
@@ -292,7 +382,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
}
let sh = wb.sheet(sheet)?;
let mut out = ClipboardHtml(String::new());
out.write_str("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
write!(out, "<table{} style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">", table_dir(sh)).ok()?;
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
@@ -310,11 +400,12 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
} else {
String::new()
};
let css = html_cell_css(wb, sh, c);
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").ok()?;
// Use the visible selected cell, even when the merge's original anchor
// is outside the range or hidden. This matches the plain-text copy.
let text = crate::display::cell_text(wb, sh, c);
let dir = CellDir::of(sh, c, &text);
let css = html_cell_css(wb, sh, c, dir);
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{}{span}>", dir.attr()).ok()?;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
@@ -357,50 +448,149 @@ impl std::fmt::Write for ClipboardHtml {
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
/// A sheet as a standalone HTML table (Save as Web Page).
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
let fixed = !(sh.images.is_empty() && sh.charts.is_empty());
let used = sh.used_range();
let mut out = String::new();
let _ = write!(
out,
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}}td{{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}}</style></head><body><table>",
esc(&sh.name)
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt{}}}td{{border:1px solid #d4d4d4;padding:{};white-space:nowrap{}}}</style></head><body>",
esc(&sh.name),
if fixed { ";table-layout:fixed" } else { "" },
if fixed { "0 4px" } else { "2px 4px" },
if fixed { ";overflow:hidden;line-height:1" } else { "" }
);
if let Some(r) = sh.used_range() {
if fixed {
out.push_str("<div style=\"position:relative\">");
}
let _ = write!(out, "<table{}", table_dir(sh));
if let (true, Some(r)) = (fixed, used) {
let width: f32 = (r.start.col..=r.end.col).map(|c| sh.col_width(c)).sum();
let _ = write!(out, " style=\"width:{width}px\"");
}
out.push('>');
if let Some(r) = used {
if r.height() > HTML_EXPORT_MAX_ROWS {
return Err(EngineError::Other(format!(
"the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.",
r.height()
)));
}
if fixed {
out.push_str("<colgroup>");
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
let _ = write!(out, "<col style=\"width:{}px\">", sh.col_width(col));
}
out.push_str("</colgroup>");
}
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
out.push_str("<tr>");
if fixed {
let _ = write!(out, "<tr style=\"height:{}px\">", sh.row_height(row));
} else {
out.push_str("<tr>");
}
for col in r.start.col..=r.end.col {
let c = CellRef::new(row, col);
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
continue;
}
let css = html_cell_css(wb, sh, c);
let text = crate::display::cell_text(wb, sh, c);
let dir = CellDir::of(sh, c, &text);
let css = html_cell_css(wb, sh, c, dir);
let span = sh
.merges
.iter()
.find(|m| m.start == c)
.map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()))
.map(|m| {
if fixed {
let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
} else {
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
}
})
.unwrap_or_default();
let _ = write!(out, "<td style=\"{css}\"{span}>{}</td>", esc(&crate::display::cell_text(wb, sh, c)));
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
}
out.push_str("</tr>");
}
}
out.push_str("</table></body></html>");
out.push_str("</table>");
if fixed {
// Sheet units are CSS pixels; the table starts at the used range, not at A1.
let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
let place = |a: &gridcraft_model::Anchor| {
format!(
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
sh.col_left(a.cell.col) - left + f64::from(a.dx),
sh.row_top(a.cell.row) - top + f64::from(a.dy),
a.width.max(0.0),
a.height.max(0.0)
)
};
for im in &sh.images {
let _ = write!(
out,
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
esc(&im.mime),
base64_encode(&im.data),
esc(&im.alt),
place(&im.anchor)
);
}
for ch in &sh.charts {
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
out.push_str(&svg);
}
}
out.push_str("</div>");
}
out.push_str("</body></html>");
Ok(out)
}
thread_local! {
/// How many [`ScriptSandbox`]es are alive on this thread.
static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
pub(crate) struct ScriptSandbox(());
impl ScriptSandbox {
pub(crate) fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(())
}
}
impl Drop for ScriptSandbox {
fn drop(&mut self) {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
}
}
/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
/// thread is being torn down).
fn script_sandbox_check(path: &str) -> Result<()> {
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 2 * 1024 * 1024 * 1024 {
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
@@ -410,11 +600,13 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
@@ -427,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
script_sandbox_check(path)?;
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
@@ -500,6 +693,190 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook {
let mut wb = Workbook::new();
let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect();
let sh = wb.sheet_mut(0).unwrap();
sh.right_to_left = rtl;
for ((at, value, _), id) in cells.iter().zip(ids) {
let c = CellRef::parse(at).unwrap();
sh.set_value(c, value.clone());
sh.set_style(c, id);
}
wb
}
fn copied(wb: &Workbook, range: &str) -> String {
let r = RangeRef::parse(range).unwrap();
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
}
#[test]
fn html_export_embeds_images_as_data_uris() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image {
id: 1,
anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() },
data: vec![1, 2, 3].into(),
mime: "image/png".into(),
alt: "a \"logo\"".into(),
});
let html = to_html(&wb, 0).unwrap();
assert!(
html.contains("<img src=\"data:image/png;base64,AQID\" alt=\"a &quot;logo&quot;\" style=\"position:absolute;left:0px;top:0px;width:72px;height:36px\"></div></body>"),
"{html}"
);
assert!(html.contains("<div style=\"position:relative\"><table"), "{html}");
assert!(!to_html(&Workbook::new(), 0).unwrap().contains("<img"), "no images, no <img>");
}
#[test]
fn html_export_places_images_at_their_anchor_using_sheet_geometry() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
sh.images.push(gridcraft_model::Image {
id: 1,
anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() },
data: vec![1].into(),
mime: "image/png".into(),
alt: String::new(),
});
sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0));
let html = to_html(&wb, 0).unwrap();
// The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on.
assert!(html.contains("<col style=\"width:100px\">"), "{html}");
assert!(html.contains("<tr style=\"height:30px\">"), "{html}");
assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}");
}
fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0));
sh.images.push(gridcraft_model::Image {
id: 1,
anchor: gridcraft_model::Anchor {
cell: CellRef::parse(anchor).unwrap(),
dx: 0.0,
dy: 0.0,
width: 10.0,
height: 10.0,
mode: Default::default(),
},
data: vec![1].into(),
mime: "image/png".into(),
alt: String::new(),
});
wb
}
#[test]
fn html_export_places_images_past_hidden_columns_and_rows() {
let mut wb = book_with_picture("C3", (0, 0));
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0));
sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
let html = to_html(&wb, 0).unwrap();
// Column B and row 2 take no room: C3 is one default column and row from A1.
assert_eq!(html.matches("<col ").count(), 2, "{html}");
assert_eq!(html.matches("<tr ").count(), 2, "{html}");
assert!(html.contains("left:64px;top:20px"), "{html}");
}
#[test]
fn html_export_places_images_above_and_left_of_the_table() {
let html = to_html(&book_with_picture("A1", (2, 2)), 0).unwrap();
// The table starts at C3, so a picture at A1 sits before it instead of failing.
assert!(html.contains("left:-128px;top:-40px"), "{html}");
}
#[test]
fn html_export_marks_right_to_left_sheets() {
use gridcraft_core::Value;
use gridcraft_model::HAlign;
let wb = book(true, &[("A1", Value::text("Hello!"), HAlign::General), ("B1", Value::Number(-5.0), HAlign::General)]);
let html = to_html(&wb, 0).unwrap();
assert!(html.contains("<body><table dir=\"rtl\">"), "{html}");
assert!(html.contains("<td style=\"\" dir=\"auto\">Hello!</td>"), "Latin text keeps its own direction: {html}");
assert!(
html.contains("<td style=\"text-align:left;\" dir=\"ltr\">-5</td>"),
"numbers mirror to the left and keep their sign in front: {html}"
);
let clip = copied(&wb, "A1:B1");
assert!(clip.starts_with("<table dir=\"rtl\" style="), "{clip}");
assert_eq!(clip.matches("dir=\"auto\"").count(), 1, "{clip}");
assert!(clip.contains("text-align:left;\" dir=\"ltr\">-5</td>"), "{clip}");
let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]);
assert!(to_html(&ltr, 0).unwrap().contains("<body><table><tr>"));
assert!(copied(&ltr, "A1").starts_with("<table style="));
}
#[test]
fn html_export_lets_arabic_general_text_align_by_direction() {
use gridcraft_core::Value;
use gridcraft_model::HAlign;
let wb = book(
false,
&[
("A1", Value::text("مرحبا!"), HAlign::General),
("B1", Value::text("שלום"), HAlign::Left),
("C1", Value::text("مرحبا"), HAlign::Center),
("D1", Value::text("Hello"), HAlign::General),
],
);
let html = to_html(&wb, 0).unwrap();
assert!(html.contains("<td style=\"\" dir=\"auto\">مرحبا!</td>"), "General Arabic text aligns by its direction: {html}");
assert!(html.contains("<td style=\"text-align:left;\" dir=\"auto\">שלום</td>"), "explicit alignment stays: {html}");
assert!(html.contains("<td style=\"text-align:center;\" dir=\"auto\">مرحبا</td>"), "{html}");
assert!(html.contains("<td style=\"text-align:left;\">Hello</td>"), "{html}");
assert!(html.contains("<body><table><tr>"), "a left-to-right sheet has no table direction: {html}");
let clip = copied(&wb, "A1:D1");
let cells: Vec<&str> = clip.split("<td ").skip(1).collect();
assert_eq!(cells.len(), 4, "{clip}");
assert!(cells[0].contains("dir=\"auto\"") && !cells[0].contains("text-align"), "{clip}");
assert!(cells[1].contains("text-align:left;") && cells[1].contains("dir=\"auto\""), "{clip}");
assert!(cells[3].contains("text-align:left;") && !cells[3].contains("dir="), "{clip}");
}
#[test]
fn html_export_of_latin_ltr_sheet_is_unchanged() {
use gridcraft_core::Value;
use gridcraft_model::HAlign;
let mut wb = book(
false,
&[
("A1", Value::text("Name"), HAlign::General),
("B1", Value::text("Qty"), HAlign::Center),
("A2", Value::text("Apples & <pears>"), HAlign::General),
("B2", Value::Number(3.0), HAlign::General),
("A3", Value::text("Total"), HAlign::Right),
],
);
wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap());
assert_eq!(
to_html(&wb, 0).unwrap(),
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Sheet1</title><style>table{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}td{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}</style></head><body><table>\
<tr><td style=\"text-align:left;\">Name</td><td style=\"text-align:center;\">Qty</td></tr>\
<tr><td style=\"text-align:left;\">Apples &amp; &lt;pears&gt;</td><td style=\"text-align:right;\">3</td></tr>\
<tr><td style=\"text-align:right;\" rowspan=\"1\" colspan=\"2\">Total</td></tr>\
</table></body></html>"
);
let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;";
assert_eq!(
copied(&wb, "A1:B2"),
format!(
"<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">\
<tr><td style=\"{cell}text-align:left;\">Name</td><td style=\"{cell}text-align:center;\">Qty</td></tr>\
<tr><td style=\"{cell}text-align:left;\">Apples &amp; &lt;pears&gt;</td><td style=\"{cell}text-align:right;\">3</td></tr></table>"
)
);
}
#[test]
fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() {
let mut wb = Workbook::new();
@@ -517,7 +894,7 @@ mod tests {
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
wb.vba_project = Some(b"vba".to_vec());
wb.vba_project = Some(b"vba".to_vec().into());
let has_macros = |path: &str| {
let bytes = save_bytes(&wb, path).unwrap();
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
@@ -561,6 +938,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// While a script runs (#2178), file reads and writes are refused whatever command or
/// parameter asked for them; nesting and dropping the sandbox restore access.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn script_sandbox_refuses_file_access_until_dropped() {
let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("in.csv");
std::fs::write(&existing, b"a,b\n").unwrap();
let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string());
{
let _outer = ScriptSandbox::enter();
{
let _inner = ScriptSandbox::enter();
assert!(read_file(&existing).is_err());
}
assert!(read_file(&existing).is_err(), "the outer sandbox still holds");
assert!(write_file(&out, b"leak").is_err());
assert!(!std::path::Path::new(&out).exists());
}
assert_eq!(read_file(&existing).unwrap(), b"a,b\n");
write_file(&out, b"ok").unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
@@ -725,6 +1128,54 @@ mod tests {
);
}
/// Where `part`'s entry starts in the central directory of `package`.
fn central_entry(package: &[u8], part: &str) -> usize {
let mut at = 0;
while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) {
let entry = at + i;
let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize;
if &package[entry + 46..entry + 46 + len] == part.as_bytes() {
return entry;
}
at = entry + 46;
}
panic!("{part} is not in the package");
}
/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the
/// declared size, so no real 512 MB part has to be built.
fn declaring(mut package: Vec<u8>, part: &str, size: u32) -> Vec<u8> {
let at = central_entry(&package, part);
package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes());
package
}
#[test]
fn a_part_over_the_size_limit_says_how_to_get_the_data_in() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big"));
let package = save_bytes(&wb, "big.xlsx").unwrap();
// The size reported in storytold/gridcraft#1242.
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string();
assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}");
assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}");
assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}");
assert!(!msg.contains("bytes"), "{msg}");
// Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps.
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string();
assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}");
assert!(!msg.contains("several sheets"), "{msg}");
// Just over the limit never reads as a size equal to it.
let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string();
assert!(msg.contains("more than 512 MB"), "{msg}");
// A package that fails for another reason keeps its own error, oversized part or not.
let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731);
let at = central_entry(&broken, "xl/workbook.xml");
broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x';
let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string();
assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}");
}
#[test]
fn content_wins_over_a_mismatched_extension() {
let mut wb = Workbook::new();
+101 -8
View File
@@ -8,6 +8,7 @@
pub mod catalog;
pub mod cf;
mod chart_svg;
mod clip_html;
pub mod cmd;
pub mod display;
@@ -61,6 +62,8 @@ pub struct HistoryEntry {
pub label: String,
pub wb: Arc<Workbook>,
pub selection: Selection,
/// Estimate of storage this snapshot retains beyond the next workbook version.
pub retained_bytes: usize,
}
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
@@ -112,15 +115,65 @@ pub struct DocState {
/// The format `path`'s content was recognised as when its extension doesn't name one (an
/// XLSX called `budget.dat`): Save writes it back in that format.
pub content_kind: Option<io::FileKind>,
/// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
/// that arrives already carrying scripts (opened from a file) starts untrusted until the
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
/// trusted, since any script it gains later was written here.
pub scripts_trusted: bool,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
const UNDO_LIMIT: usize = 100;
/// Past this many entries the journal drops its oldest half.
const JOURNAL_MAX_ENTRIES: usize = 10_000;
/// Past this many bytes (entries serialized as JSON) the journal drops its oldest entries until
/// it is under half. Entries keep their full params so they replay (recorded scripts,
/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
/// few opens of a large workbook keep tens of MB alive.
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let mut bytes = std::mem::size_of::<Workbook>();
for (index, sheet) in older.sheets.iter().enumerate() {
// Moving or inserting sheets must not charge unchanged sheets at a new index.
if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
continue;
}
bytes = bytes.saturating_add(std::mem::size_of::<gridcraft_model::Sheet>());
let empty = gridcraft_model::CellStore::new();
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
bytes
}
fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
}
fn push_history_with_budget(
history: &mut Vec<HistoryEntry>,
label: String,
wb: Arc<Workbook>,
next: &Workbook,
selection: Selection,
byte_budget: usize,
) {
let retained_bytes = history_retained_bytes(&wb, next);
history.push(HistoryEntry { label, wb, selection, retained_bytes });
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
// Always preserve the latest action, even if that one edit exceeds the budget.
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
total = total.saturating_sub(history.remove(0).retained_bytes);
}
}
impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
DocState {
@@ -137,6 +190,7 @@ impl DocState {
sheet_selections: vec![],
unlocked_ranges: vec![],
content_kind: None,
scripts_trusted,
}
}
pub fn is_dirty(&self) -> bool {
@@ -227,6 +281,13 @@ pub enum UiRequest {
OpenUrl(String),
}
/// Width in points of one line of text drawn in a cell font, measured by a front end that has
/// the font the grid draws with. With `exact` false the front end may answer with a cheaper
/// measure that is never narrower (AutoFit asks for it on columns with many distinct values).
/// Commands call it while they run, so a front end must not run commands while holding a lock
/// the measure takes (egui's: see `theme::cell_text_width` in the UI crate).
pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -236,6 +297,9 @@ pub struct Session {
pub mode: Mode,
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
pub journal: Vec<(String, Json)>,
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
journal_sizes: Vec<usize>,
journal_bytes: usize,
pub ui_requests: Vec<UiRequest>,
/// Format painter source style (sticky when double-clicked).
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
@@ -256,6 +320,9 @@ pub struct Session {
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
/// (like Excel's comment indicator option), so it's not saved in the file.
pub show_all_notes: bool,
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
/// [`cmd::format::approx_text_width`].
pub text_width: Option<TextWidth>,
}
impl Session {
@@ -309,6 +376,38 @@ impl Session {
self.add_document(DocState::new(wb, None, format!("Book{n}")))
}
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
/// keeps pointing at the same entry (the steps the trim dropped are lost).
fn push_journal(&mut self, id: &str, params: Json) {
let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());
// `journal` is public: if it was changed from outside, measure it again.
if self.journal_sizes.len() != self.journal.len() {
self.journal_sizes = self.journal.iter().map(size).collect();
self.journal_bytes = self.journal_sizes.iter().sum();
}
let entry = (id.to_string(), params);
let entry_size = size(&entry);
self.journal.push(entry);
self.journal_sizes.push(entry_size);
self.journal_bytes = self.journal_bytes.saturating_add(entry_size);
let len = self.journal.len();
let mut n = if len > JOURNAL_MAX_ENTRIES { len - JOURNAL_MAX_ENTRIES / 2 } else { 0 };
let mut kept = self.journal_bytes.saturating_sub(self.journal_sizes.iter().take(n).sum());
if self.journal_bytes > JOURNAL_MAX_BYTES {
// The newest entry stays even when it is larger than the cap on its own.
while kept > JOURNAL_MAX_BYTES / 2 && n + 1 < len {
kept = kept.saturating_sub(self.journal_sizes.get(n).copied().unwrap_or(0));
n += 1;
}
}
if n > 0 {
self.journal.drain(..n);
self.journal_sizes.drain(..n);
self.journal_bytes = kept;
self.recording = self.recording.map(|r| r.saturating_sub(n));
}
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
@@ -340,20 +439,14 @@ impl Session {
}
};
if spec.journal && result.is_ok() {
self.journal.push((id.to_string(), cmd::journal_params(&params)));
if self.journal.len() > 10_000 {
self.journal.drain(..5_000);
}
self.push_journal(id, cmd::journal_params(&params));
}
if spec.undoable
&& let Some((uid, wb, sel)) = before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&d.wb, &wb)
{
d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel });
if d.undo.len() > UNDO_LIMIT {
d.undo.remove(0);
}
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
d.redo.clear();
d.revision += 1;
}
+4 -22
View File
@@ -9,7 +9,8 @@ use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use gridcraft_core::date::datetime_from_serial;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
pub use gridcraft_core::quote_sheet;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
use gridcraft_model::*;
use crate::pivot_calc::CalcSet;
@@ -47,26 +48,6 @@ impl Source {
}
}
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
fn split_sheet(r: &str) -> (Option<String>, &str) {
match r.rsplit_once('!') {
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
None => (None, r),
}
}
/// Quotes a sheet name for a reference when needed.
pub fn quote_sheet(name: &str) -> String {
if !name.is_empty()
&& name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.')
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
{
name.to_string()
} else {
format!("'{}'", name.replace('\'', "''"))
}
}
/// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`.
pub fn range_text(sheet_name: &str, r: RangeRef) -> String {
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
@@ -1511,7 +1492,7 @@ pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
};
let (sheet, name) = t.rsplit_once('!')?;
let sheet = sheet.trim();
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
let sheet = gridcraft_core::unquote_sheet(sheet);
let name = name.trim();
if sheet.is_empty() || name.is_empty() {
return None;
@@ -1567,6 +1548,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
})
+1
View File
@@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool {
color: None,
secondary: false,
kind: None,
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
}];
+5 -1
View File
@@ -63,9 +63,13 @@ impl Selection {
pub fn a1(&self) -> String {
self.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>().join(",")
}
/// Expands areas so merged cells are included whole.
/// Expands areas so merged cells are included whole. Whole columns and whole rows stay as
/// they are: clicking column B's header selects just B even when B1:C1 is merged, as in Excel.
pub fn expand_merges(&mut self, sheet: &Sheet) {
for r in self.ranges.iter_mut() {
if r.is_full_cols() || r.is_full_rows() {
continue;
}
let mut cur = *r;
loop {
let mut grown = cur;
File diff suppressed because it is too large Load Diff
@@ -100,3 +100,26 @@ fn autofit_filter_width_is_undoable_without_changing_selection() {
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 80.0);
}
#[test]
fn autofit_measures_each_distinct_text_once_and_quickly_past_the_limit() {
use std::sync::{Arc, Mutex};
let mut s = Session::new();
s.new_workbook();
// 2,500 distinct texts, each in two cells.
let values: Vec<_> = (0..5000).map(|i| json!([format!("v{}", i % 2500)])).collect();
s.execute("range.setValues", json!({"range":"A1", "values": values})).unwrap();
let calls = Arc::new(Mutex::new(Vec::new()));
let seen = calls.clone();
s.text_width = Some(Arc::new(move |text: &str, _: &gridcraft_engine::model::Font, exact: bool| {
seen.lock().unwrap().push((text.to_owned(), exact));
if text == "v2499" { 300.0 } else { 10.0 }
}));
s.execute("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 2500, "one measure per distinct text");
assert_eq!(calls.iter().filter(|(_, exact)| *exact).count(), 2000);
assert!(calls.iter().skip(2000).all(|(_, exact)| !exact));
// The quick measures still size the column.
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 306.0);
}
@@ -0,0 +1,61 @@
//! #1999: a conditional-format rule whose formula doesn't parse must be rejected at
//! creation (Excel's object model refuses it too), not saved and poison the file.
use gridcraft_engine::Session;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
#[test]
fn invalid_cf_formulas_are_rejected() {
for rule in [
// exact repros from the issue's measured table
json!({"type": "expression", "formula": "=A1>", "preset": "lightRedFill"}),
json!({"type": "cellIs", "operator": "greaterThan", "value": "=1+", "preset": "lightRedFill"}),
json!({"type": "dataBar", "min": {"type": "formula", "value": "1+"}}),
// more garbage that the parser rejects
json!({"type": "expression", "formula": "=IF(A1", "preset": "lightRedFill"}),
json!({"type": "cellIs", "operator": "equal", "value": "=((A1", "preset": "lightRedFill"}),
// multiple leading '=' on a stored-as-typed threshold: the writer strips one,
// the rest must still parse (#1999 review)
json!({"type": "dataBar", "min": {"type": "formula", "value": "===A1"}}),
json!({"type": "dataBar", "max": {"type": "formula", "value": "==1+"}}),
] {
let err = session()
.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": rule.clone()}))
.expect_err("invalid formula {rule} must be rejected");
let msg = err.to_string();
assert!(msg.to_lowercase().contains("formula"), "error should mention the formula: {msg}");
}
}
#[test]
fn valid_cf_formulas_still_work() {
let mut s = session();
// bare comparison without '=' (the issue: this one saves fine today)
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "expression", "formula": "A1>5", "preset": "lightRedFill"}}))
.unwrap();
// leading '=' is accepted and stripped
s.execute("home.conditionalFormat", json!({"range": "B1:B5", "rule": {"type": "expression", "formula": "=B1>5", "preset": "lightRedFill"}}))
.unwrap();
// cellIs numeric and text values are not formulas, must pass untouched
s.execute(
"home.conditionalFormat",
json!({"range": "C1:C5", "rule": {"type": "cellIs", "operator": "greaterThan", "value": "5", "preset": "lightRedFill"}}),
)
.unwrap();
s.execute(
"home.conditionalFormat",
json!({"range": "D1:D5", "rule": {"type": "cellIs", "operator": "equal", "value": "hello there", "preset": "lightRedFill"}}),
)
.unwrap();
// dataBar formula thresholds that parse
s.execute("home.conditionalFormat", json!({"range": "E1:E5", "rule": {"type": "dataBar", "min": {"type": "formula", "value": "MIN(E1:E5)"}, "max": {"type": "formula", "value": "MAX(E1:E5)"}}}))
.unwrap();
// non-formula rule types are untouched
s.execute("home.conditionalFormat", json!({"range": "F1:F5", "rule": {"type": "duplicate", "preset": "lightRedFill"}})).unwrap();
}
@@ -0,0 +1,134 @@
//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name`
//! in `<c:f>`; they used to read back verbatim and evaluate to `#NAME?`, so a chart
//! driven by dynamic (OFFSET) names opened blank with a single `#NAME?` category.
use std::io::{Read, Write};
use gridcraft_engine::{Session, io};
use gridcraft_model::Workbook;
use serde_json::json;
const TASKS: usize = 6;
/// A plan table, OFFSET names over its structured references (and names built on names; one
/// scoped to the sheet), and a stacked bar chart whose series name those names.
fn plan() -> Workbook {
let mut s = Session::new();
s.new_workbook();
let mut rows = vec![json!(["Task", "Start", "Done", "Left"])];
for i in 0..TASKS {
rows.push(json!([format!("Task {}", i + 1), (45_200 + 7 * i).to_string(), (i + 1).to_string(), (TASKS - i).to_string()]));
}
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let range = format!("A1:D{}", TASKS + 1);
s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap();
for (name, refers, scope) in [
("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)", "Workbook"),
("TaskStart", "=OFFSET(TaskNames,0,1)", "Workbook"),
("TaskDone", "=OFFSET(TaskNames,0,2)", "Workbook"),
("TaskLeft", "=OFFSET(TaskNames,0,3)", "Sheet1"),
("LeftTitle", "=Sheet1!$D$1", "Workbook"),
("DoneTitle", "=Sheet1!$C$1", "Sheet1"),
] {
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
}
s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap();
let mut wb = Workbook::clone(&s.doc().unwrap().wb);
let chart = &mut wb.sheet_mut(0).unwrap().charts[0];
assert_eq!(chart.series.len(), 3);
for (ser, (name, values)) in
chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!DoneTitle", "TaskDone"), ("LeftTitle", "TaskLeft")])
{
ser.name = Some(name.into());
ser.categories = Some("TaskNames".into());
ser.values = values.into();
}
wb
}
fn chart_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut f = z.by_name("xl/charts/chart1.xml").unwrap();
let mut xml = String::new();
f.read_to_string(&mut xml).unwrap();
xml
}
/// The package with `edit` applied to its chart part.
fn edit_chart(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
if name == "xl/charts/chart1.xml" {
data = edit(String::from_utf8(data).unwrap()).into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
out.finish().unwrap().into_inner()
}
fn reopen(bytes: &[u8]) -> Workbook {
io::open_bytes("plan.xlsx", bytes).unwrap().0
}
#[test]
fn workbook_names_in_chart_series_resolve_after_save_and_reopen() {
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
// Qualified, as Excel needs them: it draws a chart with a bare name in `<c:f>` blank.
let xml = chart_xml(&bytes);
for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] {
assert!(xml.contains(&format!("<c:f>{f}</c:f>")), "{f} missing from {xml}");
}
assert!(!xml.contains("<c:f>Task"), "a bare name in {xml}");
// Series names that are defined names stay references, qualified or not.
assert!(xml.contains("<c:tx><c:strRef><c:f>Sheet1!DoneTitle</c:f>"), "{xml}");
// A series name that is a defined name stays a reference, not literal text.
assert!(!xml.contains("<c:v>LeftTitle</c:v>"));
let wb = reopen(&bytes);
let chart = &wb.sheet(0).unwrap().charts[0];
assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames"));
assert_eq!(chart.series[0].values, "TaskStart");
assert_eq!(chart.series[2].values, "Sheet1!TaskLeft");
assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle"));
let data = gridcraft_chart::resolve(&wb, 0, chart);
let tasks: Vec<String> = (1..=TASKS).map(|i| format!("Task {i}")).collect();
assert_eq!(data.categories, tasks);
let names: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["Start", "Done", "Left"]);
let start: Vec<Option<f64>> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect();
assert_eq!(data.series[0].values, start);
let left: Vec<Option<f64>> = (0..TASKS).map(|i| Some((TASKS - i) as f64)).collect();
assert_eq!(data.series[2].values, left);
// A second save and reopen gives the same chart data.
let again = reopen(&io::save_bytes(&wb, "plan.xlsx").unwrap());
assert_eq!(gridcraft_chart::resolve(&again, 0, &again.sheet(0).unwrap().charts[0]), data);
}
#[test]
fn a_missing_workbook_name_resolves_to_no_values_without_panicking() {
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
let bytes = edit_chart(&bytes, |x| x.replace("Sheet1!TaskLeft", "[0]!NoSuchName"));
let wb = reopen(&bytes);
let chart = &wb.sheet(0).unwrap().charts[0];
assert_eq!(chart.series[2].values, "NoSuchName");
let data = gridcraft_chart::resolve(&wb, 0, chart);
assert!(data.series[2].values.iter().all(Option::is_none), "{:?}", data.series[2].values);
// The other series still plot.
assert_eq!(data.series[0].values.len(), TASKS);
}
#[test]
fn external_workbook_refs_in_chart_series_are_kept_as_they_were() {
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskDone", "[1]Sheet1!$C$2:$C$7"));
let wb = reopen(&bytes);
assert_eq!(wb.sheet(0).unwrap().charts[0].series[1].values, "[1]Sheet1!$C$2:$C$7");
}
@@ -23,7 +23,7 @@
//!
//! Knobs (environment variables):
//! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`.
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`.
//! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line).
//! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that
//! still fails the same way, printed as `execute` calls ready for a regression test.
@@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure)
None => format!("end-of-run checks after {} steps", sequence.len()),
};
let mut out = format!(
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n",
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n",
f.kind, f.what
);
let end = (f.step + 1).min(sequence.len());
@@ -0,0 +1,20 @@
use gridcraft_engine::Session;
use serde_json::json;
fn visible_rows(op: &str) -> Vec<u32> {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["n"], ["3"], ["10"], [""], ["abc1"], ["20"]]})).unwrap();
s.execute("selection.set", json!({"range":"A1:A6"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column":0, "custom":{"op":op, "value":"5"}})).unwrap();
let doc = s.doc().unwrap();
let sh = doc.wb.active().unwrap();
(1..6u32).filter(|r| !sh.rows.get(r).is_some_and(|i| i.hidden)).collect()
}
#[test]
fn less_than_numeric_criterion_hides_blank_and_text_rows() {
assert_eq!(visible_rows("<"), vec![1]);
assert_eq!(visible_rows("<="), vec![1]);
}
+87
View File
@@ -0,0 +1,87 @@
//! A formula opened with dynamic-array metadata keeps it through edits that only move or adjust
//! it, and loses it when it is retyped (the writer then decides from the formula's shape).
use std::sync::Arc;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{Cell, Formula, Workbook};
use gridcraft_engine::{DocState, Session, io};
use serde_json::json;
fn at(s: &str) -> CellRef {
CellRef::parse(s).unwrap()
}
/// A workbook saved by GridCraft whose D1 formula came from a file with `cm`.
fn opened() -> Session {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
for r in 0..3 {
sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r + 1)));
sh.set_value(CellRef::new(r, 2), Value::Number(f64::from(r + 2)));
}
let mut f = Formula::new("SUMPRODUCT(B1:B3*C1:C3)");
f.dynamic = true;
sh.cells.set(at("D1"), Cell::formula(f));
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
let mut s = Session::new();
s.add_document(DocState::new(back, None, "dynamic".into()));
s
}
fn formula(s: &Session, cell: &str) -> Arc<Formula> {
s.doc().unwrap().wb.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap()
}
/// Whether `cell` of the saved file carries dynamic-array metadata.
fn saved_dynamic(s: &Session, cell: &str) -> bool {
let bytes = io::save_bytes(&s.doc().unwrap().wb, "dynamic.xlsx").unwrap();
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
back.sheet(0).unwrap().cell(at(cell)).unwrap().formula.as_ref().unwrap().dynamic
}
#[test]
fn opened_dynamic_formula_keeps_its_metadata_on_save() {
let s = opened();
assert!(formula(&s, "D1").dynamic);
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(at("D1")), Value::Number(20.0));
assert!(saved_dynamic(&s, "D1"));
}
#[test]
fn inserting_rows_and_moving_keep_the_mark() {
let mut s = opened();
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
let f = formula(&s, "D2");
assert!(f.dynamic);
assert_eq!(f.text, "SUMPRODUCT(B2:B4*C2:C4)");
assert!(saved_dynamic(&s, "D2"));
s.execute("selection.set", json!({"range": "D2"})).unwrap();
s.execute("edit.cut", json!({})).unwrap();
s.execute("selection.set", json!({"range": "F2"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert!(formula(&s, "F2").dynamic);
assert!(saved_dynamic(&s, "F2"));
}
#[test]
fn retyped_formula_drops_the_mark() {
let mut s = opened();
s.execute("cell.set", json!({"cell": "D1", "input": "=SUMPRODUCT(B1:B3*C1:C3)"})).unwrap();
assert!(!formula(&s, "D1").dynamic);
// SUMPRODUCT evaluates its arguments as arrays anyway: saved as a legacy formula.
assert!(!saved_dynamic(&s, "D1"));
}
#[test]
fn dynamic_mark_is_serialized_only_when_set() {
let s = opened();
let dynamic = serde_json::to_value(&*formula(&s, "D1")).unwrap();
assert_eq!(dynamic["dynamic"], json!(true));
let plain = serde_json::to_value(Formula::new("SUM(A1:A3)")).unwrap();
assert!(plain.get("dynamic").is_none(), "{plain}");
// Documents written before the field existed still load.
let old: Formula = serde_json::from_value(json!({"text": "SUM(A1:A3)", "array": null})).unwrap();
assert!(!old.dynamic);
}
@@ -0,0 +1,61 @@
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap();
s
}
fn styles(s: &Session) -> Vec<String> {
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect()
}
#[test]
fn format_as_table_inside_a_table_restyles_it() {
let mut s = table();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_on_a_range_inside_a_table_restyles_it() {
let mut s = table();
s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap();
assert_eq!(styles(&s), ["TableStyleDark3"]);
}
#[test]
fn format_as_table_across_a_table_edge_is_rejected() {
let mut s = table();
let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err();
assert!(e.to_string().contains("overlap"), "{e}");
assert_eq!(styles(&s), ["TableStyleMedium2"]);
}
#[test]
fn format_as_table_inside_a_table_returns_that_table() {
let mut s = table();
let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap();
assert_eq!(r, json!({"table":"T", "range":"A1:B3"}));
}
#[test]
fn format_as_table_uses_the_active_area_of_a_multi_area_selection() {
let mut s = table();
s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() {
let mut s = table();
s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
@@ -0,0 +1,78 @@
//! Sheet navigation must never activate hidden sheets when it reaches a workbook boundary.
use std::sync::Arc;
use gridcraft_engine::{Session, model::Visibility};
use serde_json::json;
fn workbook(hidden: &[usize], active: usize) -> Session {
let mut session = Session::new();
session.new_workbook();
for name in ["Second", "Third", "Fourth"] {
session.execute("home.insertSheet", json!({"name": name})).unwrap();
}
for sheet in hidden {
session.execute("sheet.hide", json!({"sheet": sheet})).unwrap();
}
session.execute("sheet.activate", json!({"sheet": active})).unwrap();
session.execute("selection.set", json!({"cell": "C7"})).unwrap();
session
}
#[test]
fn next_stays_on_the_last_visible_sheet() {
let mut session = workbook(&[2, 3], 1);
for _ in 0..2 {
session.execute("sheet.next", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 1);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
#[test]
fn previous_stays_on_the_first_visible_sheet() {
let mut session = workbook(&[0, 1], 2);
for _ in 0..2 {
session.execute("sheet.previous", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 2);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
#[test]
fn navigation_skips_hidden_sheets_in_both_directions() {
let mut session = workbook(&[1, 2], 0);
session.execute("sheet.next", json!({})).unwrap();
assert_eq!(session.active().unwrap().wb.active_sheet, 3);
session.execute("sheet.previous", json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 0);
assert_eq!(doc.selection.active.a1(), "C7");
}
#[test]
fn navigation_with_one_visible_sheet_is_a_noop() {
let mut session = workbook(&[0, 2, 3], 1);
for command in ["sheet.previous", "sheet.next"] {
session.execute(command, json!({})).unwrap();
let doc = session.active().unwrap();
assert_eq!(doc.wb.active_sheet, 1);
assert_eq!(doc.selection.active.a1(), "C7");
}
}
#[test]
fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() {
let mut session = workbook(&[1, 3], 0);
// VeryHidden is an imported XLSX state; sheet.hide only creates Hidden sheets.
let wb = Arc::make_mut(&mut session.active_mut().unwrap().wb);
for index in [1, 3] {
Arc::make_mut(&mut wb.sheets[index]).visibility = Visibility::VeryHidden;
}
for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] {
session.execute(command, json!({})).unwrap();
assert_eq!(session.active().unwrap().wb.active_sheet, expected);
}
}
@@ -0,0 +1,87 @@
//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is
//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its
//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack
//! nor fall back to an error: Excel computes it.
use gridcraft_core::{CellRef, Value};
use gridcraft_engine::{DocState, Session, io};
use serde_json::json;
/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`.
fn literal_chain(op: &str) -> String {
format!("={}", vec!["1"; 4096].join(op))
}
/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit.
fn reference_chain() -> String {
format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+"))
}
fn value(session: &Session, cell: &str) -> Value {
let at = CellRef::parse(cell).expect("cell");
session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at)
}
/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on.
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack");
}
#[test]
fn longest_flat_chains_survive_editing_saving_and_reopening() {
on_small_stack(|| {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values");
session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain");
session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain");
session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain");
session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain");
assert_eq!(value(&session, "C1"), Value::Number(4096.0));
assert_eq!(value(&session, "C2"), Value::Number(-4094.0));
assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096)));
assert_eq!(value(&session, "C4"), Value::Number(1500.0));
// Precedents of a long reference chain still drive recalculation.
session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent");
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
// Evaluate Formula walks every operator of the chain.
let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula");
assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200));
assert_eq!(steps["result"], json!(1600.0));
assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0));
// Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501).
session.execute("edit.copy", json!({"range": "C4"})).expect("copy");
session.execute("edit.paste", json!({"at": "C5"})).expect("paste");
assert_eq!(value(&session, "C5"), Value::Number(1599.0));
session.execute("edit.undo", json!({})).expect("undo paste");
// Fill and structural edits rewrite every reference in the chain (Excel: an inserted
// row above shifts A1:A1500 to A2:A1501; the sum is unchanged).
session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right");
assert_eq!(value(&session, "D1"), Value::Number(4096.0));
assert_eq!(value(&session, "D4"), Value::Number(0.0));
session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row");
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column");
assert_eq!(value(&session, "D5"), Value::Number(1600.0));
session.execute("edit.undo", json!({})).expect("undo");
session.execute("edit.undo", json!({})).expect("undo");
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
session.execute("edit.redo", json!({})).expect("redo");
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
// XLSX round trip keeps the formulas and their values.
let wb = &session.doc().expect("doc").wb;
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
let mut session = Session::new();
session.add_document(DocState::new(reopened, None, "chains".into()));
session.execute("formulas.calculateNow", json!({})).expect("recalc");
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
});
}
+29
View File
@@ -0,0 +1,29 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod autofit_filter_buttons;
mod cf_formula_validate;
mod chart_workbook_names;
mod command_sequence_fuzz;
mod cross_sheet_cut;
mod csv_export_budget;
mod custom_filter_blanks;
mod custom_view_freeze;
mod dynamicmetadata;
mod extreme_selection_moves;
mod format_as_table_restyle;
mod hidden_sheet_navigation;
mod ink_roundtrip;
mod line_count_overflow;
mod long_operator_chains;
mod offset_overflow;
mod percent_entry;
mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod row_autofit;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod table_resize_overlap;
mod table_total_row_last_row;
mod wrap_row_height;
+20
View File
@@ -0,0 +1,20 @@
//! Typing a bare number into a Percentage-formatted cell enters it as a percent (issue #1967).
use gridcraft_engine::display::cell_text;
use serde_json::json;
fn enter(input: &str) -> String {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
s.execute("home.numberFormat", json!({"range": "A1", "format": "Percentage"})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap();
let d = s.doc().unwrap();
cell_text(&d.wb, d.wb.active().unwrap(), gridcraft_engine::core::CellRef::new(0, 0))
}
#[test]
fn bare_number_in_percent_cell_is_a_percent() {
assert_eq!(enter("3"), "3.00%");
assert_eq!(enter("0.03"), "3.00%");
assert_eq!(enter("3%"), "3.00%");
}
@@ -0,0 +1,33 @@
use gridcraft_engine::Session;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Region", "Sales"], ["East", "10"], ["East", "2"], ["West", "4"]]})).unwrap();
s
}
#[test]
fn insert_pivot_table_rejects_unknown_func() {
for func in ["stdev", "sumx"] {
let mut s = session();
let r = s.execute(
"insert.pivotTable",
json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":func}]}),
);
assert!(r.is_err(), "unknown func `{func}` accepted silently");
assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty());
}
}
#[test]
fn insert_pivot_table_accepts_known_func() {
let mut s = session();
s.execute(
"insert.pivotTable",
json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":"stddev"}]}),
)
.unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 1);
}
+41
View File
@@ -0,0 +1,41 @@
//! #1447: a PivotChart is bound to its PivotTable behind the workbook's name.
use std::io::Read;
use gridcraft_engine::Session;
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) -> Json {
s.execute(command, params).unwrap()
}
fn source() -> Session {
let mut s = Session::new();
s.new_workbook();
run(
&mut s,
"range.setValues",
json!({"range": "A1", "values": [["Region ", " Sales ", "Qty"], ["East", 10, 1], ["West", 20, 2], ["East", 5, 3]]}),
);
s
}
#[test]
fn pivot_chart_link_names_the_workbook_in_brackets() {
let mut s = source();
run(&mut s, "selection.set", json!({"range": "A1"}));
run(&mut s, "insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]}));
run(&mut s, "insert.pivotChart", json!({}));
let bytes = gridcraft_engine::io::save_bytes(&s.doc().unwrap().wb, "Q1 Report.xlsx").unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut chart = String::new();
zip.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut chart).unwrap();
assert!(chart.contains("<c:name>[Q1 Report.xlsx]Sheet2!PivotTable1</c:name>"), "{chart}");
// Without a file name, a placeholder workbook is named; reading drops the brackets again.
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
let mut reopened = Session::new();
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
let link = reopened.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).find_map(|c| c.pivot.clone());
assert_eq!(link.as_deref(), Some("Sheet2!PivotTable1"));
}
@@ -0,0 +1,93 @@
//! Ctrl+Enter (`range.fill`) gives every cell what typing the input there gives it: a General
//! cell gets the input's automatic number format, a formatted cell keeps its own.
use gridcraft_engine::Session;
use serde_json::{Value, json};
fn session() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn get(s: &mut Session, a: &str) -> Value {
s.execute("cell.get", json!({"cell": a})).unwrap()
}
fn fmt_of(s: &Session, a: &str) -> String {
let wb = &s.doc().unwrap().wb;
let sh = wb.active().unwrap();
wb.styles.get(sh.style_id(gridcraft_engine::core::CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
}
/// Selects `range` with A1 active and fills it with `input`, as Ctrl+Enter does.
fn fill(s: &mut Session, range: &str, input: &str) {
s.execute("selection.set", json!({"range": range, "active": "A1"})).unwrap();
s.execute("range.fill", json!({"input": input})).unwrap();
}
#[test]
fn filled_cells_that_already_hold_a_value_get_the_automatic_format() {
for input in ["5%", "$5", "3/4/2026", "1:30 PM", "1.5E+3"] {
let mut s = session();
// What typing the input into a General cell gives.
s.execute("cell.set", json!({"cell": "C1", "input": input})).unwrap();
let typed = get(&mut s, "C1");
// A1 blank, A2 holding a number: both are General.
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
fill(&mut s, "A1:A2", input);
for a in ["A1", "A2"] {
let filled = get(&mut s, a);
assert_eq!(filled["text"], typed["text"], "{input} in {a}");
assert_eq!(filled["value"], typed["value"], "{input} in {a}");
assert_eq!(fmt_of(&s, a), fmt_of(&s, "C1"), "{input} in {a}");
}
}
}
#[test]
fn filled_cells_with_their_own_format_keep_it() {
let mut s = session();
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
s.execute("home.formatCells", json!({"range": "A2", "style": {"num_fmt": "0.000"}})).unwrap();
// Column B is formatted but has no cells yet; row 3 is Text.
s.execute("home.formatCells", json!({"range": "B:B", "style": {"num_fmt": "0.00"}})).unwrap();
s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@"}})).unwrap();
fill(&mut s, "A1:B3", "5%");
assert_eq!(fmt_of(&s, "A1"), "0%");
assert_eq!(get(&mut s, "A1")["text"], "5%");
assert_eq!(fmt_of(&s, "A2"), "0.000");
assert_eq!(get(&mut s, "A2")["text"], "0.050");
assert_eq!(fmt_of(&s, "B1"), "0.00");
assert_eq!(get(&mut s, "B1")["text"], "0.05");
// A Text cell keeps what was typed, as text.
for a in ["A3", "B3"] {
assert_eq!(get(&mut s, a)["type"], "text", "{a}");
assert_eq!(get(&mut s, a)["text"], "5%", "{a}");
}
}
#[test]
fn filled_formulas_still_shift_and_get_their_date_format() {
let mut s = session();
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
fill(&mut s, "A1:A2", "=DATE(2026,3,ROW())");
assert_eq!(get(&mut s, "A2")["formula"], "=DATE(2026,3,ROW())");
for (a, text) in [("A1", "3/1/2026"), ("A2", "3/2/2026")] {
assert_eq!(fmt_of(&s, a), "m/d/yyyy", "{a}");
assert_eq!(get(&mut s, a)["text"], text, "{a}");
}
fill(&mut s, "A1:A2", "=C1*2");
assert_eq!(get(&mut s, "A2")["formula"], "=C2*2");
}
#[test]
fn a_fill_undoes_in_one_step() {
let mut s = session();
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
fill(&mut s, "A1:A2", "5%");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(get(&mut s, "A1")["type"], "empty");
assert_eq!(get(&mut s, "A2")["value"].as_f64(), Some(1.0));
assert_eq!(fmt_of(&s, "A2"), "General");
}
@@ -0,0 +1,51 @@
//! #1458: sheet names are quoted in references the way Excel quotes them, in formulas, saved
//! files, PivotTable sources and ADDRESS.
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) -> Json {
s.execute(command, params).unwrap()
}
fn formula(s: &Session, cell: &str) -> String {
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone()
}
#[test]
fn formulas_keep_the_quotes_excel_needs_through_a_save() {
let mut s = Session::new();
s.new_workbook();
for name in [".x", "R1C1", "A1", "Bob's"] {
run(&mut s, "home.insertSheet", json!({"name": name}));
}
run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"}));
let input = "='.x'!A1+'R1C1'!A1+'A1'!B1+'Bob''s'!A1";
run(&mut s, "cell.set", json!({"cell": "A1", "input": input}));
assert_eq!(formula(&s, "A1"), input.trim_start_matches('='));
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
let mut reopened = Session::new();
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
run(&mut reopened, "sheet.activate", json!({"sheet": "Sheet1"}));
assert_eq!(formula(&reopened, "A1"), input.trim_start_matches('='));
}
#[test]
fn address_and_pivot_source_quote_a_sheet_named_like_a_cell() {
let mut s = Session::new();
s.new_workbook();
run(&mut s, "home.insertSheet", json!({"name": "A1"}));
run(&mut s, "sheet.activate", json!({"sheet": "A1"}));
for (cell, input) in [("A1", "K"), ("B1", "V"), ("A2", "a"), ("B2", "1"), ("A3", "b"), ("B3", "2")] {
run(&mut s, "cell.set", json!({"cell": cell, "input": input}));
}
run(&mut s, "cell.set", json!({"cell": "D1", "input": "=ADDRESS(1,1,1,TRUE,\"A1\")"}));
let d1 = s.doc().unwrap().wb.active().unwrap().value(CellRef::parse("D1").unwrap());
assert_eq!(d1, Value::from("'A1'!$A$1"));
run(&mut s, "selection.set", json!({"range": "A2"}));
let pivot = run(&mut s, "insert.pivotTable", json!({"rows": ["K"], "values": ["V"]}));
assert_eq!(pivot["source"], json!("'A1'!$A$1:$B$3"));
}
@@ -0,0 +1,87 @@
//! Clearing a large sparse selection must invalidate every changed cell (issue #1032).
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_engine::Session;
use serde_json::json;
fn value(s: &Session, address: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(address).unwrap())
}
#[test]
fn large_clear_updates_distant_direct_and_transitive_dependents() {
for command in ["edit.clearContents", "edit.clearAll"] {
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1000001", "7"), ("B1", "=A1000001"), ("C1", "=B1*2")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)));
// B1 stretches the used range to row 1; the changed source lies just past the old cap.
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Number(7.0), "{command} undo");
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)), "{command} undo");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command} redo");
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command} redo");
}
}
#[test]
fn large_clear_removes_a_distant_formulas_spill_and_updates_its_dependent() {
for command in ["edit.clearContents", "edit.clearAll"] {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1000001", "input": "=SEQUENCE(2)"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1000002"})).unwrap();
assert_eq!((value(&s, "A1000002"), value(&s, "B1")), (Value::Number(2.0), Value::Number(2.0)));
// The spill lies outside the selection, but must disappear when its anchor is cleared.
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
assert_eq!(value(&s, "A1000002"), Value::Empty, "{command}");
assert_eq!(value(&s, "B1"), Value::Number(0.0), "{command}");
}
}
#[test]
fn clearing_an_empty_merge_unblocks_a_spill() {
for command in ["edit.clearFormats", "edit.clearAll"] {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
// Keep the empty merged area inside the used range after the spill becomes blocked.
s.execute("cell.set", json!({"cell": "C4", "input": "1"})).unwrap();
s.execute("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
s.execute(command, json!({"range": "A2:B3"})).unwrap();
assert_eq!((value(&s, "A1"), value(&s, "A3")), (Value::Number(1.0), Value::Number(3.0)), "{command}");
assert!(s.doc().unwrap().wb.active().unwrap().merges.is_empty(), "{command}");
}
}
#[test]
fn clearing_a_distant_styled_blocker_unblocks_a_spill() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(100)"})).unwrap();
s.execute("cell.set", json!({"cell": "A90", "input": "x"})).unwrap();
s.execute("home.bold", json!({"range": "A90", "on": true})).unwrap();
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
// The cleared blocker stays stored for its style and lies beyond the local 64-row scan.
s.execute("edit.clearContents", json!({"range": "A2:A100"})).unwrap();
assert_eq!(value(&s, "A1"), Value::Number(1.0));
assert_eq!(value(&s, "A90"), Value::Number(90.0));
assert_eq!(value(&s, "A100"), Value::Number(100.0));
let wb = &s.doc().unwrap().wb;
let cell = wb.active().unwrap().cell(CellRef::parse("A90").unwrap()).unwrap();
assert_eq!(cell.value, Value::Empty);
assert!(wb.styles.get(cell.style).font.bold);
}
@@ -27,3 +27,30 @@ fn resize_to_overlapping_range_still_works() {
s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap();
assert_eq!(table_range(&s), "B1:C2");
}
fn two_tables() -> Session {
let mut s = table();
s.execute("range.setValues", json!({"range":"D1", "values":[["P", "Q"], ["y", "8"]]})).unwrap();
s.execute("insert.table", json!({"range":"D1:E2", "header":true, "name":"U"})).unwrap();
s
}
fn ranges(s: &Session) -> Vec<String> {
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.range.a1()).collect()
}
#[test]
fn resize_over_another_table_is_rejected() {
let mut s = two_tables();
assert!(s.execute("table.resize", json!({"table":"T", "range":"A1:E2"})).is_err());
assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]);
assert!(s.execute("table.resize", json!({"table":"U", "range":"A1:E2"})).is_err());
assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]);
}
#[test]
fn resize_next_to_another_table_still_works() {
let mut s = two_tables();
s.execute("table.resize", json!({"table":"T", "range":"A1:C3"})).unwrap();
assert_eq!(ranges(&s), ["A1:C3", "D1:E2"]);
}

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