76 Commits

Author SHA1 Message Date
Marcos Paulo d5fb5136f7 chore: release 0.6.2
Release / release (push) Has been cancelled
Windows ARM64 / windows-arm64 (push) Has been cancelled
Publish Flatpak repository to Pages / Build Flatpak repository (release) Has been cancelled
Publish Flatpak repository to Pages / Deploy Pages (release) Has been cancelled
2026-10-12 21:41:27 -03:00
Marcos Paulo f162240fcf build: refresh Cargo.lock for the masi/craft-libs fork rev 2026-10-12 21:08:19 -03:00
Marcos Paulo 8033af3f03 build: point craft-ui/craft-print at the masi/craft-libs fork (rebased debrand) 2026-10-12 20:53:55 -03:00
Marcos Paulo 14b6964db0 feat(ui): Catppuccin themes
Adds Latte, Frappé, Macchiato and Mocha to the Display theme choices (ribbon,
File > Options, native menu, view.theme). The craft-ui dependency comes from the
masi/craft-libs fork. ThemeMode is the one typed list the menus and command share.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:53:23 -03:00
Marcos Paulo 61201a1173 chore: debrand ArtCraft and Discord references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:53:01 -03:00
Brandon Thomas fc45b7f6d8 Release 0.6.1: rebuild with the fixed shared release workflow
Packaging lint / lint (push) Has been cancelled
CI / cargo xtask ci (push) Has been cancelled
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:15 -04:00
Brandon Thomas 9cb931bac9 Release workflow: pass the Azure signing secrets so Windows builds are signed (#440)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:25 -04:00
skylar taylor-barrick 85bfcdfcfd Merge pull request #459 from storytold/codex/craft-print-onboarding
Print: select printers and submit jobs through craft-print
2026-10-12 09:05:31 -06:00
skylar taylor-barrick 9fc4108899 Printing: expose native commands through control discovery 2026-10-12 16:50:03 +02:00
Brandon Thomas b3acae6b77 Release prep: 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:31 -04:00
skylar taylor-barrick 587d879f13 Print: adopt craft-print with native settings and job feedback 2026-10-12 03:25:47 +02:00
Brandon Thomas 3926d21aa9 Fix-up: #423's table-name round-trip test sets #433's Table.filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:08:49 -04:00
Brandon Thomas 3b19cb80e6 Merge pull request #412 from jsymes21-byte/keytips-table-refactor
Keytips: full Excel sequential Alt table — 364 sequences, badges on every control, generated tests (fixes #33, #49)

# Conflicts:
#	contributors/people.toml
2026-10-11 18:06:06 -04:00
Brandon Thomas 63e60de76c Merge pull request #413 from pechnikov2026-star/i18n-fr
i18n: add French (fr) interface
2026-10-11 18:06:05 -04:00
Brandon Thomas a34fe4a7af Merge pull request #436 from LloydNicholson/feat/open-encrypted
Open password-encrypted workbooks (Agile and Standard encryption) (#11)

# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-11 18:06:04 -04:00
Brandon Thomas 0a54876e2c Merge pull request #425 from neilcawse/test/command-sequence-fuzz
Engine: command-sequence fuzz harness (undo/redo, XLSX round-trip, full-recalc invariants) and the bugs it found
2026-10-11 18:06:03 -04:00
Brandon Thomas 61e3dd99f7 Merge pull request #416 from neilcawse/feat/oracle-check
CLI: check subcommand - cached-value calc oracle and XLSX round-trip fidelity on any corpus
2026-10-11 18:06:02 -04:00
Brandon Thomas 69b5f78e86 Merge pull request #424 from neilcawse/fix/trendlines
Charts: read, write and draw trendlines

# Conflicts:
#	docs/gaps.md
2026-10-11 18:06:01 -04:00
Brandon Thomas dcd469d12e Merge pull request #419 from neilcawse/fix/function-fixes
Functions: MODE/VALUE/SUMPRODUCT text args, ADDRESS [Book]Sheet, Excel-style lookup bisection, SUBTOTAL skips filtered rows

# Conflicts:
#	docs/file-format-parity.md
#	docs/function-parity.md
2026-10-11 18:05:41 -04:00
Brandon Thomas f170b89a92 Merge pull request #420 from neilcawse/fix/structref
Formulas: trim-reference operators (A1:.E10, A1.:E10, A1.:.E10); read unbracketed [[#This Row],Col]

# Conflicts:
#	docs/function-parity.md
#	docs/gaps.md
2026-10-11 18:05:40 -04:00
Brandon Thomas 80c2efe0f1 Merge pull request #421 from neilcawse/fix/iterative-calc
Calc: iterative calculation for circular references (Maximum Iterations, Maximum Change)

# Conflicts:
#	docs/function-parity.md
2026-10-11 18:05:39 -04:00
Brandon Thomas 74d57ea9fd Merge pull request #422 from neilcawse/fix/csv-display
File formats: CSV/TSV export writes displayed values; templates and .xlsm saved with their own content types

# Conflicts:
#	crates/engine/src/io.rs
#	crates/xlsx/src/tests/roundtrip.rs
#	docs/file-format-parity.md
2026-10-11 18:05:33 -04:00
Brandon Thomas 47b2cfbcd0 Merge pull request #415 from neilcawse/ci/pr-gate
CI: run cargo xtask ci on pull requests and pushes to main
2026-10-11 18:05:19 -04:00
Brandon Thomas a96f19f02b Merge pull request #409 from shrshk/fix/formula-arg-count-363
Refuse formulas with the wrong argument count on entry (#363)
2026-10-11 18:05:18 -04:00
Brandon Thomas e2bcb712fd Merge pull request #418 from neilcawse/fix/numfmt-text
Numfmt: fraction literals and zero fractions, `?` pads thousands separator, TEXT booleans through text section
2026-10-11 18:05:17 -04:00
Brandon Thomas 936c6a055a Merge pull request #423 from neilcawse/fix/xlsx-names-xlpm
XLSX: strip _xlpm. from LET/LAMBDA parameters on read, keep line breaks in table columns, allow leading backslash in table names

# Conflicts:
#	docs/file-format-parity.md
2026-10-11 18:05:16 -04:00
Brandon Thomas 5432419cd5 Merge pull request #427 from vedran-dobos/csv-keep-delimiter
CSV: saving keeps the separator the file was opened with (#426)
2026-10-11 18:05:14 -04:00
Vedran Dobos b13ea3e2f3 CSV: saving keeps the separator the file was opened with (#426)
A `;`-separated CSV (the usual CSV where `,` is the decimal separator) was
rewritten with `,` on save: read_csv sniffed the delimiter but the result was
dropped, and save_bytes passed a hard-coded b','. file.save, Save As .csv,
file.exportCsv, file.saveBytes and gridcraft-cli convert all went through it.

The workbook now remembers the separator it was read with
(Workbook::csv_delimiter, set by read_csv) and a CSV save writes it back.
Workbooks not read from CSV, and .tsv/.txt sources saved as .csv, keep `,`.

Regression tests: `;`, `,` and tab .csv round trips byte for byte, new and
.xlsx-origin workbooks save with `,`, and open-by-path + edit + file.save on a
`;` file keeps `;` (also through exportCsv and saveBytes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:46:27 -07:00
Brandon Thomas e6773dac3c Merge pull request #433 from derechopiano/fix/autofit-filter-button-spacing
Fix AutoFit width for table and AutoFilter dropdowns
2026-10-11 18:05:13 -04:00
Brandon Thomas 1841658978 Merge pull request #439 from itrycodinganditsbad/rotated-text-align
Rotated text follows the cell's alignment
2026-10-11 18:05:12 -04:00
Brandon Thomas f38b17fb67 Merge pull request #438 from MarekSpodBiedry/issue-428
Scatter charts of two unlabelled columns plot the first as X for one series (#428)
2026-10-11 18:05:11 -04:00
Fabio Lussi dbc04ddae0 Rotated text follows the cell's alignment (Fixes #435)
Text turned by an angle was drawn at a fixed anchor (bottom-left for upward,
top-left for downward text) and ignored the horizontal and vertical alignment,
so upright text with Middle Align in a tall merged cell sat at the bottom.

rotated_text_pos places the turned galley's bounding box in the cell per the
alignment, then derives the TextShape anchor from it. General/Bottom keep the
old placement for upright text.

Fixes #435

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:00:56 +02:00
MarekSpodBiedry 88df1670ba Scatter charts of two unlabelled columns plot the first as X for one series (#428) 2026-10-11 23:38:27 +02:00
lloyd 83d6a6fbe7 App: files on the command line open like File › Open (#11)
A password-protected file named on the command line (a Windows or Linux
double-click) now asks for its password, and open errors are shown
instead of only logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd 009364e72e XLSX: pin the UTF-16LE password encoding with a msoffcrypto known answer (#11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd 1532349227 Encrypted workbooks no password opens are named before asking for one (#11)
gridcraft_xlsx::check_encryption parses EncryptionInfo without a
password, so RC4, Extensible, certificate-only and rights-management
(IRM) files, and damaged ones, get their real message instead of a
password prompt first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd 312b4c056e UI: password dialog fixes from review (#11)
OK on the untouched field says the password is incorrect instead of
asking again; the Backstage closes once the workbook opens; app.open on
the control channel takes a password and never opens the dialog. Control
protocol docs cover file.open {password}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd 8de8bc187a Docs: encrypted workbooks open with their password (#11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd c882d169ff UI: ask for the password of an encrypted workbook (#11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd aad58d4c75 Engine: file.open decrypts password-protected workbooks (#11)
Without a password, file.open returns an error carrying
io::PASSWORD_REQUIRED (never a dialog). A decrypted workbook gets no save
path, so Save never replaces the encrypted original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd c9f03cc36a XLSX: build encrypted workbooks in code for tests (test-support feature) (#11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
lloyd 70d857a699 XLSX: decrypt Agile and Standard encrypted packages (#11)
Clean-room from [MS-OFFCRYPTO]: EncryptionInfo parsing, SHA-1/SHA-2 key
derivation with a capped spin count, AES-ECB/CBC. RC4, Extensible and
certificate-only encryption are reported as unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:28 +02:00
Shirish Veerabattini 3936f3b79c engine: correct which functions the entry check skips
IF, IFERROR, CHOOSE, SWITCH and INDEX are in the registry and are checked; the comment listed IF as skipped.

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 16:10:04 -05:00
derechopiano 1a6c864235 home.autofitColumnWidth: reserve filter dropdown space 2026-10-11 22:09:51 +01:00
Shirish Veerabattini 478f01d236 functions: keep DATEDIF's original description, add the units
Restores "Counts whole years, months or days between two dates." and
appends the units, in the multi-line form rustfmt gives a long entry.

Drops the 256-argument test case: the parser already refuses more than
255 arguments, so it never reached the entry check. (993c108's message
says entry "also refuses" them; it already did.)

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 15:57:33 -05:00
James 7a2361de7f T10: resolve conflicts with main; new upstream commands go live (slicer, pivot chart, forecast sheet, show comments) 2026-10-11 23:54:15 +03:00
James 9e9f9aec84 T9: contributor credit and ui-parity docs for the keytip work 2026-10-11 23:41:24 +03:00
James 8e7e247589 Resolve James's real-Excel checks: drop QAT/Backstage and nonexistent View sequences; arrow navigation in the colour palette
- Side-by-side and synchronous-scrolling keytips removed (do not exist in
  James's Excel); Copy H,C / Spelling R,S / Fill H,H / Sparklines N,Z,K
  confirmed already correct
- QAT digits 0-9 and Backstage account/home/options + legacy Share Workbook
  sequences dropped (no meaningful GridCraft target; not left as trash)
- Colour palette (H,H fill and F,C font colour): arrow keys move a highlight
  through the swatches, Enter applies; key events are stripped so the grid
  behind the popup does not react
2026-10-11 23:40:23 +03:00
James d2a691aea8 Fix combo navigation edge cases and wire Page Layout Sheet Options badges
- Arrows/Enter while a keytip combo is open now navigate the list instead
  of moving the filter field's text cursor (events stripped from TextEdit)
- Reopening a combo resets the filter: the reset frame requests a repaint
  so the stale filtered list never lingers on screen
- Page Layout Sheet Options: Gridlines View/Print and Headings View/Print
  checkboxes painted with VG/PG/VH/PH badges and keytip toggles
- Background rebound from PG to PGB (Excel's Page Setup GB badge); PG is
  no longer terminal
2026-10-11 23:40:23 +03:00
James 7ba871c6f2 Excel-style combo navigation (type to filter, arrows to move) and complete tab badges
- editable_combo: every dropdown keytip opens with a filter field focused;
  typing narrows the list, Up/Down move the highlight (wrapping), Enter or
  click applies, custom text applies where valid (font size). Fixes arrows
  and typing in ALT+H FF/FS/N.
- Audited all 54 painted ribbon ids against the table: real mismatches fixed
  (Data Sort/Text-to-Columns/Remove-Duplicates/Subtotal, Review Spelling/
  Accessibility/New Comment/Notes, View Zoom/Freeze, Formulas Insert Function/
  Name Manager/Define Name/Use-in-Formula/Error Checking/Evaluate, Insert Link/
  Comment/Header-Footer/Sparklines, Home Protect Sheet).
- keytips.json: dialog kind for real dialog openers; control kind for the 6
  chart-type buttons, 7 formula-category buttons, and Orientation (HFQ added,
  was missing); M-family restructured to Excel semantics (ML/MT/ME/MO/MG/MQ
  fire directly, MI1 opens Financial) with 14 unreachable children dropped;
  383 entries total.
2026-10-11 23:40:23 +03:00
James cbd9bb51c6 Dropdown keytips open their controls instead of firing empty commands; complete View badges
- New Action::Control routes a keytip to its ribbon control (popup/dropdown
  open, checkbox toggle, button handler) instead of an engine command with
  empty params. Fixes ALT+H FF/FS/N/H/H error boxes.
- Ribbon consumes a one-shot ui.keytip_invoke: font/size/number combos
  converted to the popup pattern (ComboBox has no open_memory), fill/font
  color menus force-open via SetOpenCommand, 100% zoom and Formula Bar run
  their handlers.
- keytips.json: FF/FS/N/H/H -> control kind; View/Formulas/Data dialog
  openers (WQ zoom, HFM format cells, HO*, MV, AWG, sparkline types) and
  control WVF; MSP/HVP re-targeted; dropped 15 unreachable Home sub-items
  and non-Excel HWS shrink-to-fit.
- View tab: badges on workbook views, gridlines/headings, formula bar.
- Generated suite: 399 tests incl control-kind arm; direct tests 18.
2026-10-11 23:40:23 +03:00
James 7591fca086 T7: generated per-sequence regression tests (412 tests, catalog-probed)
cargo xtask keytips now emits tests/keytips_generated.rs alongside the
table: one end-to-end test per sequence, asserting exactly what its
action does today. The generator probes the live engine catalog at
generation time (blank workbook, empty params), so each live command
asserts journaled / ran clean / surfaced its missing-params error;
pending asserts silence; dialogs assert the dialog opens; tabs assert
the ribbon switch; group levels assert the prefix stays armed.

- pending-but-landed targets get live assertions and a generation
  warning (HWS home.shrinkToFit and MAA formulas.removeArrows had
  landed since population - flipped to live)
- unreachable-sequence validation: any seq with a terminal prefix
  entry is rejected (HCP dropped - HC Copy fires first)
- ENV_SENSITIVE list keeps the checked-in file byte-stable across
  machines (review.spelling uses the system dictionary)
- sample_sequences_work_with_text_before_key covers IME text-first
  event order for one representative of each action kind
- generated_test_count_matches_the_table pins the table size

518 ui-egui tests green; all 8 CI gates pass.
2026-10-11 23:40:04 +03:00
James cd5f3f0a71 T6: pending keytips resolve at runtime and go live when their command lands
- Action::Pending now consults the engine catalog at fire time: silent
  no-op while the command is missing, runs it once it exists - no table
  edit needed when a future feature lands.
- Stripped stray backticks from 7 pending target ids so catalog lookup
  can ever match them.
- gridcraft-engine: doc(hidden) register_test_command hook so UI tests
  can prove the go-live path without touching production code.
- Tests: pending_sequences_are_silent_no_ops (no dialog/toast/journal),
  pending_sequences_go_live_when_the_command_lands (fake command fires
  via its reserved sequence), every_sequence_terminates_without_panicking
  (all 413 sequences pressed end-to-end).
2026-10-11 23:40:04 +03:00
James 659a4d5bd3 Real-Excel keytip corrections from James (Home/Insert/Formulas)
- HP -> home.percent (was wrongly numberFormat); HN stays the Number dropdown.
- H3 -> home.underline fires directly; H3U/H3D dropped as unreachable.
- HAT/HAM/HAB -> Top/Middle/Bottom Align (were unbound).
- NCM/NZT/NZS -> modern Comment/Text Box/Symbol (NC2/NZTX/NU kept as
  legacy aliases); MI1 -> Financial first item.
- Badges on the three dropdowns (font FF, font size FS, number format N)
  and the vertical alignment buttons; Print Titles paints PI.
2026-10-11 23:39:35 +03:00
neilcawse 828efab385 Engine: command-sequence fuzz harness (undo/redo, XLSX round-trip, full-recalc invariants) and the bugs it found
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse 8ea2342703 Charts: read, write and draw trendlines
Model: Series.trendlines (Vec<Trendline>, serde default) with kind
(linear, exponential, logarithmic, polynomial 2..6, power, moving
average 2..255), name, forward/backward forecast, set intercept,
equation / R² display and line colour, width and dash.

XLSX: read and write <c:trendline> in CT_Trendline child order, placed
after dLbls and before the data references in line, bar, area, scatter,
bubble and stock series; unknown types are skipped.

Chart: least-squares fits (transformed for exp/log/power, skipping
non-positive values; polynomials via normal equations on centred,
scaled x with partial pivoting), moving averages, forecasts that widen
the category or x axis, clipping to the plot, equation and R² labels and
legend entries ("Linear (Sales)"). Degenerate data draws nothing.

Engine: chart.addElement element="trendline" adds (type, series, order,
period, forward, backward, displayEquation, displayRSquared) or removes
trendlines; document.inspect lists each chart's trendlines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse 53690bace0 Functions: oracle fixes - MODE/VALUE/SUMPRODUCT text args, ADDRESS [Book]Sheet, Excel-style lookup bisection, SUBTOTAL skips filtered rows (table filter criteria kept)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse 9ce910f87f Formulas: trim-reference operators (A1:.E10, A1.:E10, A1.:.E10); read unbracketed [[#This Row],Col]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse fdb74c27dd Calc: iterative calculation for circular references (Maximum Iterations, Maximum Change)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse eecfa4074d File formats: CSV/TSV export writes displayed values; templates and .xlsm saved with their own content types
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse 13afa8c773 XLSX: read cell formulas via file→model conversion (_xlpm), keep line breaks in table columns, allow leading backslash in table names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00
neilcawse f7bc97b378 CLI: check subcommand - cached-value calc oracle and XLSX round-trip fidelity on any corpus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:30 -04:00
neilcawse ba830cacf8 CI: run cargo xtask ci on pull requests and pushes to main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:30 -04:00
neilcawse 737999f182 Numfmt: fraction literals and zero fractions, ? pads thousands separator, TEXT booleans through text section
Checked against Excel-cached TEXT() results in number- and text-format test workbooks: 226 of 352 TEXT cells mismatched before, 0 after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:30 -04:00
James 95b3641725 Paint keytip badges on every ribbon control (issue #49)
The badge painter only covered five sites, so pressing Alt+H showed
nothing past the tab letter. Now every control with a catalog command id
paints its badge automatically:

- control_badge(prefix, id): the rest of a control's unique sequence
  (HFC -> "FC" on Font Color), or the common group letter when many
  variants land on one control (HBA..HBW -> "B" on Borders).
- anchor_badge(prefix, targets): menu anchors resolve to the dominant
  group-level candidate (Pictures shows "P", not SmartArt's "M").
- ribbon.rs: 90+ control sites now paint through control_keytip/
  anchor_keytip (mechanical sweep + hand-wired anchors); split buttons
  paint at their arrow rect.
- Offscreen snapshot verified: at prefix H the Home ribbon paints
  V/X/C/FP/1/2/3U/FC/H/FG/FK/AL/AC/AR/5/6/WW/MC/K/0/9/L/T/J/I/D/O.
- Tests: 29 lib + 14 keytips, all 8 CI gates green.
2026-10-11 23:39:11 +03:00
James f11d74ee7c Populate the full Excel keytip table (408 sequences, issues #33 and #49)
Population of keytips.json from the collected Excel Alt-sequence set:
- 408 curated entries: 10 tabs, 354 command binds (64 reserved pending,
  290 live catalog commands), 2 dialog openers, 42 group levels.
- Bind-all policy: sequences whose command does not exist yet bind to the
  canonical future id as Action::Pending -- badge shows, invocation is a
  silent no-op until the command lands (ids from docs/parity-checklist).
- Group levels: 46 command entries that are strict prefixes of deeper
  sequences were demoted to menu openers so the trie never fires early
  (HV must not paste; it waits for the variant letter).
- Fire-immediately restores: HC Copy, HAN Accounting, FA Save As, FO Open,
  FI Info. Unreachable legacy aliases (HCC, HANM, FA1, FAY3, FOC, FOO, FIP)
  dropped: the parent fires first.
- ES stays a terminal dialog opener as in Excel: the A/F/T/V variants are
  dialog mnemonics, not trie sequences.
- Escape pops one trie level and returns to the armed root at a single
  letter; keytips.json is the single modifiable source; xtask keytips now
  emits through rustfmt so --check is byte-stable.
- Tests: 99 pass (27 lib + 14 keytips + suite), all 8 CI gates green.
2026-10-11 23:37:45 +03:00
James 82cf8a6bb3 Keytips: table-driven matcher and badge painting (issues #33, #49)
Move the hardcoded keytip sequence list into keytip_table.rs as the single
source of truth for both the matcher and the ribbon badge painter.

- Generalize Action::Home into Action::Tab(&'static str); the legacy
  Edit/Format prefix special-case in lib.rs is absorbed into the table.
- Badge helpers: tab_sequence (tabs), group_extension (menu anchors),
  item_letter (menu items), control_badges (command controls).
- Add tab letters for Insert (N), Page Layout (P), Formulas (M), Data (A),
  Review (R), View (W); Draw/Automate stay unbadged until contextual
  letters land.
- Tests: 5 table unit tests + tab_letters_switch_to_their_ribbon_tabs
  integration test. All 7 xtask ci gates green.
2026-10-11 23:37:24 +03:00
Shirish Veerabattini 1d1d27f9a4 ui: argument hint shows the function's description
Inside DATEDIF( the hint showed only the signature, so the values unit
takes weren't visible while typing it (#363). The hint now adds the
function's description under the signature, wrapped to the hint's
width, the same text the autocomplete list shows (translated where a
Spanish entry exists).

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 15:37:09 -05:00
Shirish Veerabattini 993c108660 functions: share the argument-count rule between call and entry
FnSpec::accepts holds the arity check (including Excel's 255-argument
limit) that functions::call and the entry check both use, so entry also
refuses 256 arguments to a variadic function. The evaluator's own check
in calc is unchanged.

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 15:37:09 -05:00
James e90ebbbbe2 Keytips: keytips.json source + xtask generator (issues #33, #49)
The sequence table is no longer hand-maintained Rust: keytips.json is the
single human-edited source (one line per sequence, grouped by tab, pending
reserves sequences for future commands), and `cargo xtask keytips`
regenerates keytip_table/generated.rs deterministically. `cargo xtask ci`
now fails if the generated table is stale.

- Action::Pending: reserved sequences show their badge and invoke silently
  until the command exists (bind-all policy).
- Intermediate group levels are implicit: the matcher waits whenever a
  pressed letter starts a deeper sequence; only terminal entries are
  listed (two-letter units like Draw's "JI" work with no extra rows).
- First populated sequence beyond the legacy set: W,V,G toggles gridlines,
  proven end-to-end by view_gridlines_sequence_runs_end_to_end.
2026-10-11 23:36:02 +03:00
James eba3957a21 Keytips: multi-letter units and implicit wait levels (Draw = "JI")
Excel shows some access keys as one multi-letter unit (Draw's "JI") that
fires only once complete. The matcher now follows the trie shape:

- A pressed letter that matches a complete sequence fires immediately.
- If it only starts deeper sequences, the matcher waits for more input
  (implicit group level, no table entry required).
- A tab with deeper sequences stays as an active level so group badges
  show; a terminal unit clears the prefix.
- tab_sequence() returns multi-letter tab units as one badge.

Draw: Alt+JI switches to the Draw tab; deeper Draw letters are left
blank for future contributors (GridCraft's Draw differs from Excel's).
2026-10-11 23:36:02 +03:00
James b415d2e835 Keytips: table-driven matcher and badge painting (issues #33, #49)
Move the hardcoded keytip sequence list into keytip_table.rs as the single
source of truth for both the matcher and the ribbon badge painter.

- Generalize Action::Home into Action::Tab(&'static str); the legacy
  Edit/Format prefix special-case in lib.rs is absorbed into the table.
- Badge helpers: tab_sequence (tabs), group_extension (menu anchors),
  item_letter (menu items), control_badges (command controls).
- Add tab letters for Insert (N), Page Layout (P), Formulas (M), Data (A),
  Review (R), View (W); Draw/Automate stay unbadged until contextual
  letters land.
- Tests: 5 table unit tests + tab_letters_switch_to_their_ribbon_tabs
  integration test. All 7 xtask ci gates green.
2026-10-11 23:36:02 +03:00
sanches 2f22346807 i18n: add French (fr) interface
A complete 379-entry catalog covering every JAPANESE key (the coverage
reference), registered as a Fr variant in the Language enum. The
uniqueness/coverage test now asserts French alongside the other
languages; locale tags fr-FR and fr_CA resolve.
2026-10-11 23:27:39 +03:00
Shirish Veerabattini 9b59966cc9 Merge upstream main into fix/formula-arg-count-363
Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 15:20:56 -05:00
Shirish Veerabattini d0ff4265c7 contributors: add shrshk
Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 13:29:14 -05:00
Shirish Veerabattini 56f784473d engine: refuse formulas with the wrong argument count on entry
A formula calling a built-in function with too few or too many
arguments (=DATEDIF(a,b), =SUM(), =DATE(2024,1)) was stored and showed
#VALUE! with no hint about what was wrong. Entry now refuses it with
the function's signature, e.g. "DATEDIF has too few arguments. Use
DATEDIF(start_date, end_date, unit)", and leaves the cell unchanged.

Only registered functions are checked: special forms the evaluator
handles itself (IF, LET, LAMBDA...), LAMBDA names and unknown functions
are skipped. Paste and Find & Replace keep turning input that fails
entry into text. DATEDIF's description now lists its units.

Fixes #363

Claude-Session: https://claude.ai/code/session_01BGBBGBC2gRpQ7GDuwQ6WPt
2026-10-11 13:29:14 -05:00
119 changed files with 17763 additions and 766 deletions
+48
View File
@@ -0,0 +1,48 @@
# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land
# a red tree that only the next agent session discovers.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Linux build dependencies (winit, wgpu, rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
run: cargo xtask ci
+6
View File
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+1 -1
View File
@@ -25,7 +25,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
+1 -1
View File
@@ -25,7 +25,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
Generated
+1735 -32
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -35,10 +35,13 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
sha2 = { version = "0.11", default-features = false }
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
aes = { version = "0.9", default-features = false }
sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
+7 -2
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-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -32,7 +32,7 @@ This page is the summary. The detail is in:
|---|---|---|---|
| Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile |
| Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) |
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` and encrypted import 28–42 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and
calibration](docs/target-app-parity.md#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy
@@ -132,6 +132,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
| 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) |
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
@@ -175,6 +178,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row |
+3
View File
@@ -18,3 +18,6 @@ gridcraft-engine = { workspace = true }
gridcraft-mcp = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# `check`: validating the packages GridCraft writes (content types, relationships).
zip = { workspace = true }
quick-xml = { workspace = true }
File diff suppressed because it is too large Load Diff
+6
View File
@@ -5,6 +5,7 @@
//! gridcraft-cli convert <in> <out> [--sheet NAME]
//! gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
//! gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
//! gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
//! gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet]
//! gridcraft-cli commands [--json] [--search X]
//! gridcraft-cli functions [--json] [--search X] [--category C]
@@ -28,6 +29,8 @@ macro_rules! outln {
}};
}
mod check;
use gridcraft_engine::Session;
use serde_json::{Value, json};
@@ -36,6 +39,8 @@ const USAGE: &str = "usage:
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
[--out FILE] [--print RANGE] [--quiet]
gridcraft-cli commands [--json] [--search X]
@@ -71,6 +76,7 @@ fn main() -> ExitCode {
Some("eval") => eval(rest),
Some("cat") => cat(rest),
Some("run") => run(rest),
Some("check") => return check::main(rest),
Some("commands") => commands(rest),
Some("functions") => functions(rest),
Some("mcp") => mcp(rest),
+24
View File
@@ -150,3 +150,27 @@ fn mcp_over_stdio() {
assert_eq!(lines[0]["result"]["serverInfo"]["name"], "gridcraft");
assert!(lines[1]["result"]["content"][0]["text"].as_str().unwrap().contains("42"));
}
#[test]
fn check_exit_codes_and_json() {
let dir = tmpdir("check");
let book = dir.join("calc.xlsx");
let book_s = book.to_str().unwrap();
ok(&["run", "--cmd", r#"cell.set={"cell":"A1","input":"2"}"#, "--cmd", r#"cell.set={"cell":"B1","input":"=A1*3"}"#, "--out", book_s, "--quiet"]);
// GridCraft's own file: its cached values are its own results, and it round-trips.
let o = run(&["check", dir.to_str().unwrap()]);
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{out}");
assert!(out.contains("1 checked: 1 matched, 0 mismatched"), "{out}");
let v: serde_json::Value = serde_json::from_str(&ok(&["check", book_s, "--json"])).unwrap();
assert_eq!(v["schema"], "gridcraft-check/1");
assert_eq!(v["totals"]["matched"], 1);
// A file that can't be opened is reported, not fatal, and fails the check.
let o = run(&["check", book_s, "/no/such/file.xlsx"]);
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stdout).contains("1 failed to open"));
// Usage errors exit 2.
assert_eq!(run(&["check"]).status.code(), Some(2));
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
}
+1
View File
@@ -24,6 +24,7 @@ image = { workspace = true }
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
rfd = { workspace = true }
arboard = { workspace = true }
+15 -68
View File
@@ -16,6 +16,7 @@ mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod print;
use std::cell::RefCell;
use std::rc::Rc;
@@ -39,6 +40,10 @@ impl eframe::App for App {
apple_events::poll(&self.2, &mut self.0, ctx);
}
self.0.logic(ctx);
self.0.poll_printing(ctx);
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@@ -53,6 +58,7 @@ impl eframe::App for App {
self.0.ui(ui);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0);
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown;
@@ -180,12 +186,8 @@ fn services() -> Services {
dialog.save_file().and_then(|p| p.to_str().map(str::to_string))
})),
open_url: Some(Box::new(|url: &str| open_with_system(url))),
// File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print
// command reaches the printer dialog (GridCraft has no printer driver of its own).
print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) {
Ok(path) => open_with_system(&path.to_string_lossy()),
Err(e) => log::warn!("print: {e}"),
})),
printers: Some(print::discover()),
print_native: Some(print::submit()),
..Default::default()
}
}
@@ -204,32 +206,6 @@ fn open_with_system(target: &str) {
}
}
/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the
/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively
/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable
/// only by the user.
fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
use std::io::Write;
static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
for _ in 0..64 {
let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
match options.open(&path) {
Ok(mut file) => {
file.write_all(bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name"))
}
fn icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
@@ -304,19 +280,20 @@ fn main() -> eframe::Result<()> {
{
log::warn!("sample {s}: {e}");
}
for f in &files {
if let Err(e) = session.execute("file.open", json!({"path": f})) {
log::warn!("{f}: {e}");
}
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.after_engine(); // Show warnings from files opened on the command line.
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
// opens them: a password-protected one asks for its password, and errors are shown, not
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
for f in &files {
app.open_path(f);
}
let mut viewport = egui::ViewportBuilder::default()
.with_title("GridCraft")
.with_inner_size([1440.0, 900.0])
@@ -392,33 +369,3 @@ fn main() -> eframe::Result<()> {
}
result
}
#[cfg(test)]
mod print_tests {
#[test]
fn print_pdfs_get_fresh_names_in_the_given_folder() {
let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap();
let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap();
assert_ne!(a, b);
assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf"));
assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b");
assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err());
// A file (or symlink) already at the next name is skipped, never overwritten.
let pid = std::process::id();
let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect();
for p in &taken {
std::fs::write(p, b"keep").unwrap();
}
let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap();
assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep"));
assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
})
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
Duplex::Off => craft_print::Duplex::Off,
}),
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "GridCraft document".into(),
..Default::default()
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
receiver
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_waits_for_pending_submission_workers() {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
}
}
+7
View File
@@ -54,3 +54,10 @@ sync_github_name = true
[people.GhostMitra]
sync_github_name = true
[people.shrshk]
real_name = "Shirish Veerabattini"
sync_github_name = true
[people.jsymes21-byte]
real_name = "James Symes"
sync_github_name = true
+9 -2
View File
@@ -352,6 +352,9 @@ impl<'h> Evaluator<'h> {
}
fn unary(&mut self, op: UnOp, x: &Expr) -> Ev {
if let UnOp::Trim(mode) = op {
return self.trim_ref(mode, x);
}
if op == UnOp::Spill {
return match self.eval(x) {
Ev::R(areas) => match areas.as_slice() {
@@ -386,7 +389,7 @@ impl<'h> Evaluator<'h> {
UnOp::Neg => Value::number(-n),
UnOp::Plus => Value::number(n),
UnOp::Percent => Value::number(n / 100.0),
UnOp::At | UnOp::Spill => Value::number(n),
UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n),
},
op == UnOp::Plus,
))
@@ -1308,6 +1311,9 @@ impl<'h> Evaluator<'h> {
return err(CellError::Value);
}
let skip_hidden = matches!(options, 1 | 3 | 5 | 7);
// SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's
// SUBTOTAL documentation); 101-111 also leave out rows hidden by hand.
let skip_filtered = name == "SUBTOTAL";
let skip_errors = matches!(options, 2 | 3 | 6 | 7);
let skip_nested = options <= 3;
let mut values = Vec::new();
@@ -1325,7 +1331,8 @@ impl<'h> Evaluator<'h> {
let (hidden, nested) = {
let wb = self.host.workbook();
let s = wb.sheet(a.sheet);
let hidden = skip_hidden && s.is_some_and(|s| s.is_row_hidden(c.row));
let hidden =
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
let nested = skip_nested
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
let u = f.text.to_ascii_uppercase();
+154 -45
View File
@@ -6,7 +6,9 @@
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
//! calculation is on.
//! calculation is on: then a formula read while it is being evaluated gives its previous value,
//! and the formulas that read one are evaluated again until the loop settles
//! (`PassHost::iterate_loops`).
use std::collections::{BTreeSet, VecDeque};
use std::hash::{BuildHasherDefault, Hasher};
@@ -58,6 +60,10 @@ const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
/// `a..=b`?" in O(log n + k).
///
@@ -389,6 +395,44 @@ struct PassHost<'a> {
cycle: bool,
cycles: Vec<Key>,
depth: usize,
/// Iterative calculation: a circular reference reads the cell's previous value instead of
/// giving `#CIRC!`.
iterate: bool,
/// Values from the previous iteration; cells not in it read their stored value.
prev: HashMap<Key, Value>,
/// Formulas being evaluated, innermost last (kept only when iterating).
stack: Vec<Key>,
/// Formulas found on a circular reference (when iterating).
members: HashSet<Key>,
/// Results that read a previous value, directly or through other formulas.
tainted: HashSet<Key>,
/// Whether the formula being evaluated has read a previous value.
taint: bool,
}
impl<'a> PassHost<'a> {
fn new(wb: &'a Workbook, exprs: &'a HashMap<Key, std::sync::Arc<Expr>>, rng: &'a mut u64, now: f64) -> Self {
PassHost {
wb,
exprs,
results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()),
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng,
now,
cycle: false,
cycles: vec![],
depth: 0,
iterate: false,
prev: HashMap::default(),
stack: Vec::new(),
members: HashSet::default(),
tainted: HashSet::default(),
taint: false,
}
}
}
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
@@ -415,18 +459,29 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
impl PassHost<'_> {
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
return v.clone();
let v = v.clone();
if self.iterate && self.tainted.contains(&k) {
self.taint = true;
}
return v;
}
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
if self.in_progress.contains(&k) || self.depth > 2000 {
self.cycle = true;
if self.iterate {
return self.circular_read(k);
}
self.cycles.push(k);
return Value::Error(CellError::Circ);
}
self.in_progress.insert(k);
self.depth += 1;
let outer_taint = std::mem::replace(&mut self.taint, false);
if self.iterate {
self.stack.push(k);
}
let expr = std::sync::Arc::clone(expr);
let v = {
let mut ev = Evaluator::new(self, k.0, k.1);
@@ -441,6 +496,15 @@ impl PassHost<'_> {
self.depth -= 1;
self.in_progress.remove(&k);
self.pending.remove(&k);
if self.iterate {
self.stack.pop();
if self.taint {
self.tainted.insert(k);
} else {
self.tainted.remove(&k);
}
}
self.taint |= outer_taint;
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
// of their own, which stay blank.
@@ -472,6 +536,68 @@ impl PassHost<'_> {
self.results.insert(k, v.clone());
v
}
/// A formula's previous value for iterative calculation: from the last iteration, else the
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
fn previous(&self, k: Key) -> Value {
let v = match self.prev.get(&k) {
Some(v) => v.clone(),
None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(),
};
if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v }
}
/// Reading `k` while it is being evaluated, with iterative calculation on: its previous value.
/// The formulas around the loop are the ones whose changes decide when iteration stops.
fn circular_read(&mut self, k: Key) -> Value {
self.taint = true;
match self.stack.iter().rposition(|s| *s == k) {
Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()),
// Too deep to follow: the formula itself is watched.
None => {
self.members.insert(k);
}
}
self.previous(k)
}
/// Iterative calculation, after a first pass over `order` that met circular references. The
/// formulas that read a previous value (the loops and what depends on them) are evaluated
/// again in the same order, each pass starting from the last one's values and reading what it
/// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or
/// more, or after `max_iterations` passes in all. Returns the number of passes.
fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 {
let again: Vec<Key> = order.iter().copied().filter(|k| self.tainted.contains(k)).collect();
let set: HashSet<Key> = again.iter().copied().collect();
// Bounds the work whatever Maximum Iterations says.
let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1);
let mut passes = 1u32;
while passes < max_iterations.min(max_passes) && !self.converged(max_change) {
self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect();
self.pending = set.clone();
self.spills.retain(|a, _| !set.contains(a));
for k in &again {
self.compute(*k);
}
passes += 1;
}
passes
}
/// Whether every formula on a loop changed by less than `max_change` in the last pass (text,
/// logical and error values: not at all).
fn converged(&self, max_change: f64) -> bool {
self.members.iter().all(|k| {
let Some(new) = self.results.get(k) else { return true };
match (new, &self.previous(*k)) {
(Value::Number(a), Value::Number(b)) => {
let d = (a - b).abs();
d < max_change || d == 0.0
}
(new, old) => new == old,
}
})
}
}
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
@@ -532,6 +658,9 @@ impl Host for PassHost<'_> {
if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col {
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
if dr < arr.rows && dc < arr.cols {
if self.iterate && self.tainted.contains(anchor) {
self.taint = true;
}
return arr.get(dr, dc).cloned().unwrap_or_default();
}
}
@@ -590,6 +719,18 @@ impl Host for PassHost<'_> {
v
})
.collect();
// Iterating: an array spilled by a formula that read a previous value.
if self.iterate
&& self.spills.iter().any(|(a, arr)| {
let end = CellRef::new(
a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)),
a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range)
})
{
self.taint = true;
}
for (i, v) in new {
if let Some(slot) = out.get_mut(i) {
*slot = v;
@@ -825,23 +966,17 @@ impl Calc {
let mut rng = self.rng;
let tp = prof_now();
let (results, spills, cycles) = {
let mut host = PassHost {
wb,
exprs: &exprs,
results: HashMap::with_capacity_and_hasher(dirty.len(), Default::default()),
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now,
cycle: false,
cycles: vec![],
depth: 0,
};
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
host.iterate = wb.calc.iterative;
for k in &dirty {
host.compute(*k);
}
if host.iterate && !host.members.is_empty() {
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
if std::env::var_os("GRIDCRAFT_PROFILE").is_some() {
eprintln!(" iterative calculation: {} passes over {} formulas", passes, host.tainted.len());
}
}
(host.results, host.spills, host.cycles)
};
self.rng = rng;
@@ -961,20 +1096,7 @@ pub fn evaluate(wb: &Workbook, sheet: usize, at: CellRef, formula: &str) -> Valu
pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value {
let exprs = HashMap::default();
let mut rng = 0x1234_5678_9ABC_DEF0u64;
let mut host = PassHost {
wb,
exprs: &exprs,
results: HashMap::default(),
pending: HashSet::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: now_serial(),
cycle: false,
cycles: vec![],
depth: 0,
};
let mut host = PassHost::new(wb, &exprs, &mut rng, now_serial());
let mut ev = Evaluator::new(&mut host, sheet, at);
ev.value(expr)
}
@@ -998,7 +1120,7 @@ pub(crate) mod tests {
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost};
use super::{Calc, HashMap, Host, Intervals, Key, PassHost};
/// xorshift64 for repeatable pseudo-random cases.
fn rng(state: &mut u64) -> u64 {
@@ -1214,20 +1336,7 @@ pub(crate) mod tests {
// of a real pass partway through; `visit_range` has to cope with the formulas it reads
// being calculated under it.
let mut rng = 1u64;
let mut host = PassHost {
wb,
pending: exprs.keys().copied().collect(),
exprs: &exprs,
results: HashMap::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: 0.0,
cycle: false,
cycles: vec![],
depth: 0,
};
let mut host = PassHost::new(wb, &exprs, &mut rng, 0.0);
let mut seen = Vec::new();
host.visit_range(0, range, &mut |v| {
+185
View File
@@ -171,6 +171,105 @@ fn errors_and_cycles() {
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
}
impl T {
fn iterative(max_iterations: u32, max_change: f64) -> T {
let mut t = T::new();
t.wb.calc.iterative = true;
t.wb.calc.set_max_iterations(max_iterations);
t.wb.calc.set_max_change(max_change);
t
}
}
#[test]
fn iterative_calculation_stops_at_maximum_iterations() {
// A1 =A1+1 never settles: every recalculation adds Maximum Iterations.
let mut t = T::iterative(100, 0.001);
t.set("A1", "=A1+1");
assert_eq!(t.num("A1"), 100.0);
assert!(t.calc.circular.is_empty());
// An edit outside the loop doesn't recalculate it.
t.set("B1", "7");
assert_eq!(t.num("A1"), 100.0);
// Each recalculation starts from the value the cell shows.
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 200.0);
t.wb.calc.set_max_iterations(5);
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 205.0);
// Through a range that holds the formula itself.
t.set("C1", "1");
t.set("C2", "2");
t.set("C3", "=SUM(C1:C3)");
assert_eq!(t.num("C3"), 15.0);
// Excel's largest Maximum Iterations.
let mut t = T::iterative(32767, 0.001);
t.set("A1", "=A1+1");
assert_eq!(t.num("A1"), 32767.0);
}
#[test]
fn iterative_calculation_converges_within_maximum_change() {
let mut t = T::iterative(100, 0.001);
t.set("A1", "=(A2+10)/2");
t.set("A2", "=A1");
assert!((t.num("A1") - 10.0).abs() < 0.002, "{}", t.num("A1"));
assert!((t.num("A2") - 10.0).abs() < 0.002, "{}", t.num("A2"));
assert!(t.calc.circular.is_empty());
// A loop fed by a constant, and a formula that reads the loop, follow a change.
t.set("D1", "10");
t.set("A1", "=(A2+D1)/2");
t.set("E1", "=A1*2");
t.set("D1", "30");
assert!((t.num("A1") - 30.0).abs() < 0.002, "{}", t.num("A1"));
assert!((t.num("E1") - 60.0).abs() < 0.004, "{}", t.num("E1"));
// Already settled: recalculating changes it by less than Maximum Change.
let a1 = t.num("A1");
t.calc.recalc_all(&mut t.wb);
assert!((t.num("A1") - a1).abs() < 0.001);
}
#[test]
fn iterative_calculation_stops_once_changes_are_below_maximum_change() {
// A1 halves its distance to 10 each pass, from 5: 7.5, 8.75, 9.375, 9.6875, and A2 trails
// one pass behind. Maximum Change 1 stops once neither moved by 1 or more.
let mut t = T::iterative(100, 1.0);
t.set("A1", "=(A2+10)/2");
assert_eq!(t.num("A1"), 5.0);
t.set("A2", "=A1");
assert_eq!((t.num("A1"), t.num("A2")), (9.6875, 9.375));
// Maximum Iterations 2 stops first.
let mut t = T::iterative(2, 1.0);
t.set("A1", "=(A2+10)/2");
t.set("A2", "=A1");
assert_eq!((t.num("A1"), t.num("A2")), (8.75, 7.5));
}
#[test]
fn iterative_calculation_of_text_and_errors() {
let mut t = T::iterative(100, 0.001);
t.set("A1", "=IF(ISTEXT(A1),A1&\"\",\"go\")");
assert_eq!(t.get("A1"), Value::from("go"));
t.set("B1", "=B1+1/0");
assert_eq!(t.get("B1"), Value::Error(CellError::Div0));
}
#[test]
fn circular_references_without_iterative_calculation() {
let mut t = T::new();
t.set("A1", "=A1+1");
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.calc.circular, vec![(0, c("A1"))]);
// Switched on, the loop is calculated from 0 rather than from the error.
t.wb.calc.iterative = true;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 100.0);
assert!(t.calc.circular.is_empty());
t.wb.calc.iterative = false;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
#[test]
fn operators() {
let mut t = T::new();
@@ -246,6 +345,7 @@ fn arrays_do_not_spill_in_tables() {
first_col: false,
last_col: false,
filter_button: false,
filter: vec![],
});
t.set("C4", "=SEQUENCE(2)");
t.set("E1", "=SEQUENCE(4)");
@@ -462,6 +562,52 @@ fn subtotal_skips_hidden() {
assert_eq!(t.num("B2"), 6.0);
}
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
/// under a filtered table adds only the visible rows).
#[test]
fn subtotal_skips_filtered_rows() {
use gridcraft_model::{AutoFilter, FilterCriterion, LineInfo};
let mut t = T::new();
for (i, v) in [1, 2, 4, 8, 16, 32].iter().enumerate() {
t.set(&format!("A{}", i + 2), &v.to_string());
t.set(&format!("C{}", i + 2), &v.to_string());
}
let crit = || vec![(0, FilterCriterion::Custom { a: (">".into(), "1".into()), b: None, and: false })];
let sh = t.wb.sheet_mut(0).unwrap();
sh.tables.push(gridcraft_model::Table {
id: 1,
name: "Sales".into(),
range: gridcraft_core::RangeRef::parse("A1:A7").unwrap(),
header_row: true,
totals_row: false,
columns: vec![gridcraft_model::TableColumn { name: "Amount".into(), totals: Default::default(), totals_label: None, formula: None }],
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
filter: crit(),
});
sh.autofilter = Some(AutoFilter { range: gridcraft_core::RangeRef::parse("C1:C7").unwrap(), criteria: crit() });
// Rows 3 and 5 are filtered out; row 9 (outside both filters) is hidden by hand.
for row in [2, 4, 8] {
sh.rows.insert(row, LineInfo { hidden: true, ..Default::default() });
}
t.set("A9", "64");
t.set("E1", "=SUBTOTAL(9,Sales[Amount])");
t.set("E2", "=SUBTOTAL(109,Sales[Amount])");
t.set("E3", "=SUBTOTAL(9,C2:C7)");
t.set("E4", "=SUBTOTAL(9,A2:A9)");
t.set("E5", "=SUBTOTAL(109,A2:A9)");
t.set("E6", "=SUBTOTAL(2,A2:A7)");
t.set("E7", "=AGGREGATE(9,4,A2:A7)");
for (at, want) in [("E1", 53.0), ("E2", 53.0), ("E3", 53.0), ("E4", 117.0), ("E5", 53.0), ("E6", 4.0), ("E7", 63.0)] {
assert_eq!(t.num(at), want, "{at}");
}
}
#[test]
fn recalc_all_after_load() {
let mut t = T::new();
@@ -783,3 +929,42 @@ fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() {
assert_eq!(walked, None, "walked a block holding a spill it can't see");
}
/// Trim references evaluate as `TRIMRANGE(range, code, code)`.
#[test]
fn trim_references() {
let mut t = T::new();
t.set("C3", "1");
t.set("D5", "2");
t.set("A20", "5");
t.set("A21", "6");
let cases = [
("J1", "=ROWS(A1.:.F10)", "=ROWS(TRIMRANGE(A1:F10,3,3))", 3.0),
("J2", "=COLUMNS(A1.:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,3,3))", 2.0),
("J3", "=ROWS(A1:.F10)", "=ROWS(TRIMRANGE(A1:F10,2,2))", 5.0),
("J4", "=COLUMNS(A1:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,2,2))", 4.0),
("J5", "=ROWS(A1.:F10)", "=ROWS(TRIMRANGE(A1:F10,1,1))", 8.0),
("J6", "=MIN(ROW(A1.:F10))", "=MIN(ROW(TRIMRANGE(A1:F10,1,1)))", 3.0),
("J7", "=SUM(A:.A)", "=SUM(TRIMRANGE(A:A,2,2))", 11.0),
("J8", "=ROWS(A:.A)", "=ROWS(TRIMRANGE(A:A,2,2))", 21.0),
("J9", "=ROWS(A.:.A)", "=ROWS(TRIMRANGE(A:A,3,3))", 2.0),
("J10", "=ROWS(20.:.30)", "=ROWS(TRIMRANGE(20:30,3,3))", 2.0),
];
for (at, f, _, _) in cases {
t.set(at, f);
}
for (i, (at, f, same, want)) in cases.iter().enumerate() {
assert_eq!(t.num(at), *want, "{f}");
let k = format!("K{}", i + 1);
t.set(&k, same);
assert_eq!(t.num(&k), *want, "{same}");
}
// The trimmed block spills and follows edits to the full range.
t.set("L1", "=C1.:.F10");
assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0));
t.set("A22", "4");
assert_eq!((t.num("J7"), t.num("J8")), (15.0, 22.0));
// Nothing but blanks.
t.set("K20", "=SUM(Q1:.Q9)");
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
}
+21 -2
View File
@@ -2,7 +2,7 @@
//! documented semantics: trim_rows / trim_cols 0 none, 1 leading, 2 trailing, 3 both).
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use gridcraft_formula::{Expr, TrimMode};
use crate::eval::{Area, Ev, Evaluator, MAX_CELLS};
@@ -46,6 +46,20 @@ impl Evaluator<'_> {
}
}
/// A trim reference `A1.:E10` / `A1:.E10` / `A1.:.E10`: `TRIMRANGE(range, code, code)`.
pub(crate) fn trim_ref(&mut self, mode: TrimMode, x: &Expr) -> Ev {
let flags = match mode {
TrimMode::Leading => (true, false),
TrimMode::Trailing => (false, true),
TrimMode::All => (true, true),
};
let ev = self.eval(x);
match self.trim_ev(ev, flags, flags) {
Ok(ev) => ev,
Err(e) => Ev::V(Value::Error(e)),
}
}
fn trim_mode(&mut self, args: &[Expr], i: usize) -> Result<(bool, bool), CellError> {
match args.get(i) {
None | Some(Expr::Missing) => Ok((true, true)),
@@ -63,8 +77,13 @@ impl Evaluator<'_> {
};
let rows = self.trim_mode(args, 1)?;
let cols = self.trim_mode(args, 2)?;
let ev = self.eval(first);
self.trim_ev(ev, rows, cols)
}
fn trim_ev(&mut self, ev: Ev, rows: (bool, bool), cols: (bool, bool)) -> Result<Ev, CellError> {
let none = rows == (false, false) && cols == (false, false);
match self.eval(first) {
match ev {
Ev::R(areas) => {
let [a] = areas.as_slice() else { return Err(CellError::Value) };
if none {
+14 -3
View File
@@ -15,17 +15,18 @@ mod raster;
mod render;
mod resolve;
mod sparkline;
mod trend;
#[cfg(test)]
mod tests;
pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind};
pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind, Trendline, TrendlineKind};
use serde::{Deserialize, Serialize};
pub use axis::{Scale, format_number, nice_scale};
pub use raster::rasterize;
pub use render::{Swatch, legend_entries, render};
pub use resolve::{default_palette, resolve, series_color};
pub use render::{Swatch, legend_entries, render, supports_trendline};
pub use resolve::{default_palette, resolve, series_color, series_plot_kind};
pub use sparkline::render_sparkline;
/// Straight (non-premultiplied) RGBA colour.
@@ -140,10 +141,19 @@ pub struct SeriesData {
pub secondary: bool,
/// Draw line segments as a smoothed (spline) curve.
pub smooth: bool,
/// Trendlines to fit and draw over this series.
pub trendlines: Vec<TrendlineData>,
/// Number format code of the source values ("General" by default).
pub number_format: String,
}
/// A trendline to draw: the model settings with the line colour resolved against the theme.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TrendlineData {
pub spec: Trendline,
pub color: Rgba,
}
impl Default for SeriesData {
fn default() -> Self {
SeriesData {
@@ -155,6 +165,7 @@ impl Default for SeriesData {
kind: ChartKind::ColumnClustered,
secondary: false,
smooth: false,
trendlines: vec![],
number_format: "General".into(),
}
}
+229 -19
View File
@@ -3,10 +3,11 @@
use std::collections::HashSet;
use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LegendPos, Theme};
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, series_color};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
use crate::trend::{auto_name, fit_trendline, moving_average};
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF];
@@ -158,20 +159,28 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
let total = data.series.first().map(|s| s.color).unwrap_or(TEXT);
vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)]
}
_ => data
.series
.iter()
.take(MAX_SERIES)
.map(|s| {
let sw = match plot_kind(chart, s) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
_ => Swatch::Box,
};
(s.name.clone(), s.color, sw)
})
.collect(),
_ => {
let series = data.series.iter().take(MAX_SERIES);
let mut out: Vec<(String, Rgba, Swatch)> = series
.clone()
.map(|s| {
let sw = match plot_kind(chart, s) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
_ => Swatch::Box,
};
(s.name.clone(), s.color, sw)
})
.collect();
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
}
}
out
}
}
}
@@ -195,6 +204,7 @@ fn sanitize(d: &ChartData) -> ChartData {
kind: s.kind,
secondary: s.secondary,
smooth: s.smooth,
trendlines: s.trendlines.iter().take(MAX_TRENDLINES).cloned().collect(),
number_format: s.number_format.clone(),
})
.collect(),
@@ -630,6 +640,160 @@ fn value_range(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
})
}
// ---------------------------------------------------------------- trendlines
/// Samples along a regression trendline.
const TREND_STEPS: usize = 200;
/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column,
/// bar, line, area, scatter and bubble charts).
pub fn supports_trendline(k: ChartKind) -> bool {
use ChartKind::*;
matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble)
}
/// A forecast distance as drawn: finite, non-negative and bounded.
fn forecast(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
}
/// Longest forward forecast among `s`'s regression trendlines.
fn max_forward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
}
/// Longest backward forecast among `s`'s regression trendlines.
fn max_backward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
}
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
/// end were cut.
#[allow(clippy::type_complexity)]
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let r = q / p;
if p < 0.0 {
if r > t1 {
return None;
}
t0 = t0.max(r);
} else {
if r < t0 {
return None;
}
t1 = t1.min(r);
}
}
}
Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0))
}
/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit
/// square), returning the visible runs.
fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec<Vec<(f64, f64)>> {
const LIM: f64 = 1e9;
let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite();
let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM));
let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
let mut run: Vec<(f64, f64)> = Vec::new();
for w in pts.windows(2) {
let seg = match w {
[Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)),
_ => None,
};
match seg {
Some((p, q, cut_start, cut_end)) => {
if cut_start || run.is_empty() {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
run.push(p);
}
run.push(q);
if cut_end {
runs.push(std::mem::take(&mut run));
}
}
None => {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
}
}
}
if run.len() >= 2 {
runs.push(run);
}
runs
}
/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to
/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels.
/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing.
fn draw_trendlines(
ctx: &mut Ctx,
s: &SeriesData,
pts: &[(f64, f64)],
frac: &dyn Fn(f64, f64) -> (f64, f64),
px: &dyn Fn((f64, f64)) -> [f32; 2],
labels: &mut Vec<String>,
) {
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
let spec = &t.spec;
let samples: Vec<Option<(f64, f64)>> = match spec.kind {
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
_ => {
let Some(fit) = fit_trendline(spec, pts) else { continue };
let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward));
if spec.display_equation {
labels.push(fit.equation());
}
if spec.display_r_squared {
labels.push(fit.r_squared());
}
(0..=TREND_STEPS)
.map(|i| {
let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64;
fit.eval(x).map(|y| frac(x, y))
})
.collect()
}
};
let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0);
let dash = spec.dash != Some(LineDash::Solid);
for run in clip_unit(&samples) {
let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS);
if line.len() >= 2 {
ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash });
}
}
}
}
/// Trendline equation / R² labels, stacked in the plot's top-right corner.
fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
let lh = LABEL_SIZE * 1.3;
for (i, t) in labels.iter().enumerate() {
let y = plot.y + 4.0 + i as f32 * lh;
if y + lh > plot.bottom() {
break;
}
let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0);
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top);
}
}
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -763,14 +927,23 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let Some((s1, f1)) = axes.first() else { return };
let y1 = ValAxis { scale: *s1, fmt: f1 };
let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f });
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
// A forward forecast on a category axis adds empty category slots after the data.
let fwd = data
.series
.iter()
.enumerate()
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
.map(|(_, s)| max_forward(s))
.fold(0.0, f64::max);
let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat));
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } };
let cat_len = if horizontal { plot.h } else { plot.w };
let cw = cat_len / ncat as f32;
let cw = cat_len / ncat_slots as f32;
let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw };
let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h };
@@ -900,6 +1073,25 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
}
}
}
// Trendlines on top: x is the category number 1..n.
let mut trend_labels = Vec::new();
for (i, s) in data.series.iter().enumerate() {
if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) {
continue;
}
let sc = scale_of(i);
let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect();
let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min));
let px = |(fx, fy): (f64, f64)| {
if horizontal {
[plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h]
} else {
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
}
};
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
}
draw_trend_labels(ctx, plot, &trend_labels);
}
// ---------------------------------------------------------------- scatter & bubble
@@ -922,7 +1114,14 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
.collect()
};
let all: Vec<Vec<Option<(f64, f64)>>> = data.series.iter().map(pts_of).collect();
let Some((xlo, xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return };
let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return };
// Forecasts extend the x axis to the trendline's ends.
for (s, pts) in data.series.iter().zip(&all) {
if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) {
xlo = xlo.min(lo - max_backward(s));
xhi = xhi.max(hi + max_forward(s));
}
}
let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return };
let xs = nice_scale(xlo, xhi, false);
let ys = nice_scale(ylo, yhi, false);
@@ -976,6 +1175,17 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
}
}
}
let mut trend_labels = Vec::new();
for (s, pts) in data.series.iter().zip(&all) {
if s.trendlines.is_empty() {
continue;
}
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
}
draw_trend_labels(ctx, plot, &trend_labels);
}
// ---------------------------------------------------------------- histogram
+18 -1
View File
@@ -5,12 +5,14 @@ use gridcraft_core::{CellRef, RangeRef, Value, number_to_text};
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
use gridcraft_numfmt::{NumberFormat, format_value};
use crate::{ChartData, Rgba, SeriesData};
use crate::{ChartData, Rgba, SeriesData, TrendlineData};
/// Cap on points per series (keeps whole-column references from exploding).
pub(crate) const MAX_POINTS: usize = 100_000;
/// Cap on series per chart.
pub(crate) const MAX_SERIES: usize = 255;
/// Cap on trendlines drawn per series.
pub(crate) const MAX_TRENDLINES: usize = 16;
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
@@ -181,6 +183,11 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usiz
}
}
/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's.
pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {
if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind }
}
/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X
/// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the
/// default palette derived from the workbook theme accents).
@@ -208,6 +215,15 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
let trendlines = s
.trendlines
.iter()
.take(MAX_TRENDLINES)
.map(|t| TrendlineData {
spec: t.clone(),
color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
})
.collect();
series.push(SeriesData {
name,
values,
@@ -217,6 +233,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
kind,
secondary: s.secondary,
smooth: s.smooth,
trendlines,
number_format: ref_format(wb, sheet, &s.values),
});
}
+217
View File
@@ -62,6 +62,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat
kind,
secondary: false,
smooth: false,
trendlines: Vec::new(),
number_format: "$#,##0".into(),
}
}
@@ -226,6 +227,7 @@ fn resolve_from_workbook() {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
},
Series {
name: None,
@@ -236,6 +238,7 @@ fn resolve_from_workbook() {
secondary: true,
kind: None,
smooth: false,
trendlines: Vec::new(),
},
Series {
name: Some("Literal".into()),
@@ -246,6 +249,7 @@ fn resolve_from_workbook() {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
},
];
let d = resolve(&wb, 0, &c);
@@ -283,6 +287,7 @@ fn resolve_from_workbook() {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
},
Series {
name: None,
@@ -293,6 +298,7 @@ fn resolve_from_workbook() {
secondary: true,
kind: None,
smooth: false,
trendlines: Vec::new(),
},
];
let d = resolve(&wb, 0, &c);
@@ -308,6 +314,7 @@ fn resolve_from_workbook() {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
@@ -324,6 +331,7 @@ fn resolve_from_workbook() {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 7, &c);
assert_eq!(d.series.len(), 1);
@@ -583,6 +591,28 @@ fn fuzz_random_data_and_sizes() {
kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize],
secondary: r.next().is_multiple_of(3),
smooth: r.next().is_multiple_of(4),
trendlines: (0..r.next() % 3)
.map(|_| TrendlineData {
spec: Trendline {
kind: [
TrendlineKind::Linear,
TrendlineKind::Exponential,
TrendlineKind::Logarithmic,
TrendlineKind::Polynomial((r.next() % 8) as u8),
TrendlineKind::Power,
TrendlineKind::MovingAverage((r.next() % 6) as u32),
][(r.next() % 6) as usize],
forward: [0.0, 1.5, -2.0, f64::NAN, 1e300][(r.next() % 5) as usize],
backward: [0.0, 0.5, f64::INFINITY][(r.next() % 3) as usize],
intercept: [None, Some(0.0), Some(-5.0), Some(f64::NAN)][(r.next() % 4) as usize],
display_equation: r.next().is_multiple_of(2),
display_r_squared: r.next().is_multiple_of(2),
width: [None, Some(0.0), Some(f32::NAN), Some(3.0)][(r.next() % 4) as usize],
..Trendline::default()
},
color: [0, 0, 0, 255],
})
.collect(),
number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(),
})
.collect();
@@ -631,6 +661,193 @@ fn sparklines() {
assert!(render_sparkline(SparklineKind::Line, &[], [0; 4], false, 10.0, 10.0).is_empty());
}
// ---------------------------------------------------------------- trendlines
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * (1.0 + b.abs())
}
fn fit(kind: TrendlineKind, pts: &[(f64, f64)]) -> crate::trend::TrendFit {
crate::trend::fit_trendline(&Trendline { kind, ..Trendline::default() }, pts).expect("fits")
}
#[test]
fn linear_trendline_matches_least_squares() {
// Slope 0.6, intercept 2.2, R² = 1 - 2.4 / 6.
let f = fit(TrendlineKind::Linear, &[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 4.0), (5.0, 5.0)]);
assert!(close(f.coef[0], 2.2) && close(f.coef[1], 0.6), "{:?}", f.coef);
assert!(close(f.r2.unwrap(), 0.6));
assert_eq!(f.equation(), "y = 0.6x + 2.2");
assert_eq!(f.r_squared(), "R² = 0.6");
assert!(close(f.eval(10.0).unwrap(), 8.2));
let down = fit(TrendlineKind::Linear, &[(0.0, 1.0), (1.0, -1.0), (2.0, -3.0)]);
assert_eq!(down.equation(), "y = -2x + 1");
}
#[test]
fn set_intercept_fits_through_it() {
// Through the origin: slope Σxy / Σx² = 30 / 14; R² against the uncentred total.
let pts = [(1.0, 2.0), (2.0, 5.0), (3.0, 6.0)];
let f = crate::trend::fit_trendline(&Trendline { intercept: Some(0.0), ..Trendline::default() }, &pts).unwrap();
assert!(close(f.coef[0], 0.0) && close(f.coef[1], 15.0 / 7.0), "{:?}", f.coef);
let ss_res: f64 = pts.iter().map(|(x, y)| (y - 15.0 / 7.0 * x).powi(2)).sum();
assert!(close(f.r2.unwrap(), 1.0 - ss_res / 65.0));
let q = crate::trend::fit_trendline(
&Trendline { kind: TrendlineKind::Polynomial(2), intercept: Some(2.0), ..Trendline::default() },
&[(1.0, 0.0), (2.0, 0.0), (3.0, 2.0), (4.0, 6.0)],
)
.unwrap();
assert!(close(q.coef[0], 2.0) && close(q.coef[1], -3.0) && close(q.coef[2], 1.0), "{:?}", q.coef);
}
#[test]
fn transformed_trendlines_recover_their_curves() {
let xs: [f64; 6] = [0.5, 1.0, 2.0, 3.0, 4.5, 6.0];
let e = fit(TrendlineKind::Exponential, &xs.map(|x| (x, 3.0 * (0.5 * x).exp())));
assert!(close(e.coef[0].exp(), 3.0) && close(e.coef[1], 0.5));
assert_eq!(e.equation(), "y = 3e^(0.5x)");
let l = fit(TrendlineKind::Logarithmic, &xs.map(|x| (x, 2.0 * x.ln() + 1.0)));
assert!(close(l.coef[1], 2.0) && close(l.coef[0], 1.0));
assert_eq!(l.equation(), "y = 2ln(x) + 1");
let p = fit(TrendlineKind::Power, &xs.map(|x| (x, 4.0 * x.powf(1.5))));
assert!(close(p.coef[0].exp(), 4.0) && close(p.coef[1], 1.5));
assert_eq!(p.equation(), "y = 4x^1.5");
assert!(close(p.r2.unwrap(), 1.0));
assert_eq!(p.eval(-1.0), None, "power is undefined left of zero");
// Points the transform can't take are skipped, as Excel documents.
let skip = fit(TrendlineKind::Exponential, &[(1.0, 2.0f64.exp()), (2.0, 0.0), (3.0, -4.0), (4.0, 8.0f64.exp())]);
assert!(close(skip.coef[1], 2.0) && close(skip.coef[0], 0.0), "{:?}", skip.coef);
assert!(
crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::Power, ..Trendline::default() }, &[(-1.0, 1.0), (0.0, 2.0), (3.0, 3.0)])
.is_none()
);
}
#[test]
fn polynomial_trendlines_are_stable() {
let q = fit(TrendlineKind::Polynomial(2), &[0.0, 1.0, 2.0, 3.0, 4.0].map(|x| (x, x * x - 3.0 * x + 2.0)));
assert!(close(q.coef[0], 2.0) && close(q.coef[1], -3.0) && close(q.coef[2], 1.0), "{:?}", q.coef);
assert_eq!(q.equation(), "y = x² - 3x + 2");
// Year-valued x would make the raw normal equations hopelessly ill-conditioned.
let curve = |x: f64| {
let t = x - 2000.0;
0.001 * t.powi(6) - 0.02 * t.powi(5) + 0.1 * t.powi(3) - t + 7.0
};
let years: Vec<(f64, f64)> = (2001..=2012).map(|x| (f64::from(x), curve(f64::from(x)))).collect();
let six = fit(TrendlineKind::Polynomial(6), &years);
for x in [2001.0, 2006.5, 2012.0, 2014.0] {
let (got, want) = (six.eval(x).unwrap(), curve(x));
assert!((got - want).abs() <= 1e-6 * (1.0 + want.abs()), "{x}: {got} vs {want}");
}
assert!(six.r2.unwrap() > 0.999_999);
}
#[test]
fn degenerate_trendlines_fit_nothing() {
let lin = |pts: &[(f64, f64)]| crate::trend::fit_trendline(&Trendline::default(), pts);
assert!(lin(&[]).is_none());
assert!(lin(&[(1.0, 1.0)]).is_none());
assert!(lin(&[(2.0, 1.0), (2.0, 5.0), (2.0, 3.0)]).is_none(), "all x equal is singular");
assert!(lin(&[(f64::NAN, 1.0), (1.0, f64::INFINITY), (2.0, 3.0)]).is_none());
assert!(
crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::Polynomial(3), ..Trendline::default() }, &[(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)])
.is_none()
);
assert!(
crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, &[(1.0, 1.0), (2.0, 2.0)])
.is_none()
);
let flat = lin(&[(1.0, 4.0), (2.0, 4.0), (3.0, 4.0)]).unwrap();
assert_eq!((flat.equation(), flat.r2), ("y = 4".to_string(), None));
assert!(lin(&[(0.0, 1e300), (1.0, -1e300), (2.0, 1e300)]).is_none_or(|f| f.coef.iter().all(|c| c.is_finite())));
}
#[test]
fn moving_average_and_labels() {
let pts = [(1.0, 1.0), (2.0, 2.0), (3.0, 6.0), (4.0, f64::NAN), (5.0, 4.0)];
assert_eq!(crate::trend::moving_average(&pts, 2), vec![(2.0, 1.5), (3.0, 4.0), (5.0, 5.0)]);
assert_eq!(crate::trend::moving_average(&pts, 3), vec![(3.0, 3.0), (5.0, 4.0)]);
assert!(crate::trend::moving_average(&pts, 9).is_empty());
assert_eq!(crate::trend::num_text(0.000012345), "1.2345E-05");
assert_eq!(crate::trend::num_text(-0.00001), "-1E-05");
assert_eq!(crate::trend::num_text(1234.56789), "1234.5679");
assert_eq!(crate::trend::auto_name(TrendlineKind::MovingAverage(3), "Sales"), "3 per. Mov. Avg. (Sales)");
assert_eq!(crate::trend::auto_name(TrendlineKind::Exponential, "Sales"), "Expon. (Sales)");
}
fn lines_of(prims: &[Prim], color: Rgba) -> Vec<&Vec<[f32; 2]>> {
prims
.iter()
.filter_map(|p| match p {
Prim::Line { pts, color: c, .. } if *c == color => Some(pts),
_ => None,
})
.collect()
}
#[test]
fn line_chart_draws_trendline_forecast_and_labels() {
let m = ApproxMeasure;
let tc: Rgba = [1, 2, 3, 255];
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
s.trendlines =
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let mut c = chart(ChartKind::Line);
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
let prims = render(&c, &d, 600.0, 400.0, &m);
assert_sane(&prims, 600.0, 400.0);
let data = lines_of(&prims, [10, 20, 30, 255]);
let trend = lines_of(&prims, tc);
assert_eq!((data.len(), trend.len()), (1, 1));
let (d0, dn) = (data[0][0], data[0][3]);
let (t0, tn) = (trend[0][0], *trend[0].last().unwrap());
// Exact data: the trendline starts on the first point and runs through the last,
// then on one more category (the forecast slot).
assert!((t0[0] - d0[0]).abs() < 0.01 && (t0[1] - d0[1]).abs() < 0.01, "{t0:?} vs {d0:?}");
let step = (dn[0] - d0[0]) / 3.0;
assert!((tn[0] - (dn[0] + step)).abs() < 0.01, "forecast end {tn:?}, last point {dn:?}");
assert!(tn[1] > dn[1], "keeps falling past the data");
let t = texts(&prims);
assert!(t.contains(&"y = -2x + 11") && t.contains(&"R² = 1"), "{t:?}");
assert!(legend_entries(&chart(ChartKind::Line), &d).iter().any(|(n, col, sw)| n == "Linear (Sales)" && *col == tc && *sw == Swatch::Line));
// Not on chart kinds Excel doesn't offer trendlines on.
let pie = render(&chart(ChartKind::Pie), &d, 600.0, 400.0, &m);
assert!(lines_of(&pie, tc).is_empty());
// One point: nothing to fit, nothing drawn.
s.values.truncate(1);
let one = ChartData { categories: vec!["a".into()], series: vec![s] };
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
}
#[test]
fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
let m = ApproxMeasure;
let xs = [1.0, 2.0, 4.0, 8.0];
let mut s = series("P", &xs.map(|x| 4.0 * x * x), [10, 20, 30, 255], ChartKind::Scatter);
s.x = Some(xs.iter().map(|x| Some(*x)).collect());
let colors: [Rgba; 3] = [[1, 0, 0, 255], [2, 0, 0, 255], [3, 0, 0, 255]];
s.trendlines = vec![
TrendlineData {
spec: Trendline { kind: TrendlineKind::Power, backward: 0.5, display_equation: true, ..Trendline::default() },
color: colors[0],
},
// A steep exponential leaves the plot box and is clipped to it.
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
];
let d = ChartData { categories: vec![], series: vec![s] };
let mut c = chart(ChartKind::Scatter);
c.legend = LegendPos::None;
let prims = render(&c, &d, 600.0, 400.0, &m);
assert_sane(&prims, 600.0, 400.0);
for c in colors {
assert!(!lines_of(&prims, c).is_empty(), "{c:?} not drawn");
}
assert_eq!(lines_of(&prims, colors[2])[0].len(), 3, "moving average: one point per full window");
assert!(texts(&prims).contains(&"y = 4x^2"));
}
#[test]
fn treemap_and_histogram_shapes() {
let d = ChartData {
+278
View File
@@ -0,0 +1,278 @@
//! Trendline maths: least-squares fits behind chart trendlines and moving averages.
//!
//! Exponential, logarithmic and power trendlines are linear fits of transformed data
//! (`ln y` against `x`, `y` against `ln x`, `ln y` against `ln x`), the way Excel documents them;
//! points where the transform is undefined (non-positive values) are skipped and R² is reported
//! for the transformed fit. Polynomials solve the normal equations on centred, scaled x.
use gridcraft_model::{Trendline, TrendlineKind};
/// A fitted regression trendline.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TrendFit {
kind: TrendlineKind,
/// Coefficients `c0..=cd` of `Y = Σ cₖ·Xᵏ` in the transformed variables, for the equation.
pub coef: Vec<f64>,
/// The same polynomial in `u = (X - center) / scale`, for stable evaluation.
center: f64,
scale: f64,
a: Vec<f64>,
/// Coefficient of determination; `None` when undefined (all Y equal).
pub r2: Option<f64>,
}
/// Polynomial degree in the transformed variable; `None` for moving averages.
fn degree(k: TrendlineKind) -> Option<usize> {
match k {
TrendlineKind::Linear | TrendlineKind::Exponential | TrendlineKind::Logarithmic | TrendlineKind::Power => Some(1),
TrendlineKind::Polynomial(o) => Some(usize::from(o.clamp(2, 6))),
TrendlineKind::MovingAverage(_) => None,
}
}
fn log_x(k: TrendlineKind) -> bool {
matches!(k, TrendlineKind::Logarithmic | TrendlineKind::Power)
}
fn log_y(k: TrendlineKind) -> bool {
matches!(k, TrendlineKind::Exponential | TrendlineKind::Power)
}
/// Fits `t` to the points `(x, y)`. `None` for moving averages and degenerate data (too few
/// usable points, all x equal, a singular system, non-finite results).
pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFit> {
let kind = t.kind;
let d = degree(kind)?;
let data: Vec<(f64, f64)> = pts
.iter()
.filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0))
.map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y }))
.collect();
if data.len() < 2 || data.len() <= d {
return None;
}
// Set Intercept applies to linear, exponential and polynomial trendlines; it fixes Y at x = 0.
let fixed = match kind {
TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept,
TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln),
_ => None,
}
.filter(|b| b.is_finite());
let n = data.len() as f64;
let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::<f64>() / n };
let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max);
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let first = usize::from(fixed.is_some());
let b0 = fixed.unwrap_or(0.0);
// Normal equations over the unknown powers `first..=d` of u.
let m = d + 1 - first;
let mut ata = vec![vec![0.0f64; m]; m];
let mut aty = vec![0.0f64; m];
for &(x, y) in &data {
let u = (x - center) / scale;
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
for (i, pi) in pows.iter().enumerate() {
if let Some(v) = aty.get_mut(i) {
*v += pi * (y - b0);
}
for (j, pj) in pows.iter().enumerate() {
if let Some(v) = ata.get_mut(i).and_then(|r| r.get_mut(j)) {
*v += pi * pj;
}
}
}
}
let sol = solve(ata, aty)?;
let mut a = vec![b0; d + 1];
for (k, v) in sol.into_iter().enumerate() {
if let Some(slot) = a.get_mut(k + first) {
*slot = v;
}
}
let coef = expand(&a, center, scale)?;
let fit = TrendFit { kind, coef, center, scale, a, r2: None };
let mean = data.iter().map(|p| p.1).sum::<f64>() / n;
let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64);
for &(x, y) in &data {
ss_res += (y - fit.poly(x)).powi(2);
// A set intercept is LINEST(y - b, x, FALSE): the total sum of squares is not centred.
ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) };
}
let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot);
Some(TrendFit { r2, ..fit })
}
/// Gaussian elimination with partial pivoting; `None` when (nearly) singular.
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
let n = b.len();
let big = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
if !(big.is_finite() && big > 0.0) {
return None;
}
for col in 0..n {
let piv = (col..n).max_by(|&i, &j| {
let vi = a.get(i).and_then(|r| r.get(col)).map_or(0.0, |v| v.abs());
let vj = a.get(j).and_then(|r| r.get(col)).map_or(0.0, |v| v.abs());
vi.total_cmp(&vj)
})?;
a.swap(col, piv);
b.swap(col, piv);
let p = *a.get(col)?.get(col)?;
if !p.is_finite() || p.abs() <= big * 1e-13 {
return None;
}
let prow = a.get(col)?.clone();
let pb = *b.get(col)?;
for r in col + 1..n {
let row = a.get_mut(r)?;
let f = *row.get(col)? / p;
for (v, pv) in row.iter_mut().zip(&prow).skip(col) {
*v -= f * pv;
}
*b.get_mut(r)? -= f * pb;
}
}
let mut x = vec![0.0f64; n];
for r in (0..n).rev() {
let row = a.get(r)?;
let s: f64 = (r + 1..n).map(|c| row.get(c).copied().unwrap_or(0.0) * x.get(c).copied().unwrap_or(0.0)).sum();
let v = (b.get(r)? - s) / row.get(r)?;
*x.get_mut(r)? = v;
}
x.iter().all(|v| v.is_finite()).then_some(x)
}
/// Coefficients in X of `Σ aₖ·((X - c) / s)ᵏ` (binomial expansion).
fn expand(a: &[f64], c: f64, s: f64) -> Option<Vec<f64>> {
let mut out = vec![0.0f64; a.len()];
for (k, ak) in a.iter().enumerate() {
let lead = ak / s.powi(k as i32);
let mut binom = 1.0f64;
for j in 0..=k {
// C(k, j) · (-c)^(k-j) · X^j
if let Some(o) = out.get_mut(j) {
*o += lead * binom * (-c).powi((k - j) as i32);
}
binom = binom * (k - j) as f64 / (j + 1) as f64;
}
}
out.iter().all(|v| v.is_finite()).then_some(out)
}
impl TrendFit {
/// The fitted polynomial at transformed `x` (Horner in u).
fn poly(&self, x: f64) -> f64 {
let u = (x - self.center) / self.scale;
self.a.iter().rev().fold(0.0, |acc, c| acc * u + c)
}
/// The trendline's y at `x`; `None` outside its domain (x ≤ 0 for log/power) or when not finite.
pub(crate) fn eval(&self, x: f64) -> Option<f64> {
let xx = if log_x(self.kind) { if x > 0.0 { x.ln() } else { return None } } else { x };
let p = self.poly(xx);
let y = if log_y(self.kind) { p.exp() } else { p };
y.is_finite().then_some(y)
}
/// Equation label text, e.g. `y = 2x + 1`, `y = 0.5x² - 2x + 1`, `y = 3e^(0.2x)`.
pub(crate) fn equation(&self) -> String {
let c0 = self.coef.first().copied().unwrap_or(0.0);
let c1 = self.coef.get(1).copied().unwrap_or(0.0);
match self.kind {
TrendlineKind::Exponential => format!("y = {}e^({}x)", num_text(c0.exp()), num_text(c1)),
TrendlineKind::Power => format!("y = {}x^{}", num_text(c0.exp()), num_text(c1)),
TrendlineKind::Logarithmic => terms(&[(c1, "ln(x)"), (c0, "")]),
_ => {
let parts: Vec<(f64, &str)> = self.coef.iter().enumerate().rev().map(|(k, c)| (*c, POWERS.get(k).copied().unwrap_or("x"))).collect();
terms(&parts)
}
}
}
/// R² label text, e.g. `R² = 0.9963`.
pub(crate) fn r_squared(&self) -> String {
match self.r2 {
Some(r) => format!("R² = {}", num_text(r)),
None => "R² = #N/A".into(),
}
}
}
const POWERS: [&str; 7] = ["", "x", "x²", "x³", "x⁴", "x⁵", "x⁶"];
/// `y = a·t1 ± b·t2 …`, skipping terms whose coefficient shows as 0.
fn terms(parts: &[(f64, &str)]) -> String {
let mut s = String::from("y = ");
let mut first = true;
for (c, t) in parts {
let txt = num_text(c.abs());
if txt == "0" {
continue;
}
let neg = *c < 0.0;
if first {
if neg {
s.push('-');
}
} else {
s.push_str(if neg { " - " } else { " + " });
}
// A unit coefficient on a power of x is left out: `y = x² - x + 1`.
if txt != "1" || t.is_empty() {
s.push_str(&txt);
}
s.push_str(t);
first = false;
}
if first {
s.push('0');
}
s
}
/// A coefficient as trendline labels show it: up to four decimals, scientific when tiny or huge.
pub(crate) fn num_text(v: f64) -> String {
if !v.is_finite() {
return "#NUM!".into();
}
let a = v.abs();
if a == 0.0 {
return "0".into();
}
if !(1e-4..1e11).contains(&a) {
let s = format!("{v:.4E}");
let (mant, exp) = s.split_once('E').unwrap_or((s.as_str(), "0"));
let mant = trim_zeros(mant);
let e: i32 = exp.parse().unwrap_or(0);
return format!("{mant}E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs());
}
let s = trim_zeros(&format!("{v:.4}"));
if s == "-0" { "0".into() } else { s }
}
fn trim_zeros(s: &str) -> String {
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s.to_string() }
}
/// Moving average of `period` consecutive points in plotted order: point `i` (from
/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`.
pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
let p = period.clamp(2, 255) as usize;
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
}
/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`.
pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String {
let k = match kind {
TrendlineKind::Linear => "Linear".to_string(),
TrendlineKind::Exponential => "Expon.".into(),
TrendlineKind::Logarithmic => "Log.".into(),
TrendlineKind::Polynomial(_) => "Poly.".into(),
TrendlineKind::Power => "Power".into(),
TrendlineKind::MovingAverage(p) => format!("{} per. Mov. Avg.", p.clamp(2, 255)),
};
format!("{k} ({series})")
}
+1
View File
@@ -25,4 +25,5 @@ gridcraft-pdf = { workspace = true }
image = { workspace = true }
[dev-dependencies]
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
zip = { workspace = true }
+5
View File
@@ -444,6 +444,9 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
let sh = cx.sheet_mut(sheet)?;
if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) {
t.filter_button = !t.filter_button;
if !t.filter_button {
t.filter.clear();
}
}
Ok(Json::Null)
});
@@ -624,6 +627,8 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
},
FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col,
FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col,
// Icons are not evaluated here: the row keeps its current visibility.
FilterCriterion::Icon { .. } => !sh.is_row_hidden(row),
};
if !ok {
visible = false;
+66 -5
View File
@@ -150,6 +150,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
}
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
let expr = wb.name_call_case(expr);
check_arg_counts(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
// Formulas whose result is a date/time get a format from functions like TODAY().
@@ -190,6 +191,28 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
Ok(Some(Cell { value: parsed.value, formula: None, style }))
}
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
/// unknown functions are skipped.
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut bad = None;
expr.walk(&mut |e| {
if bad.is_none()
&& let gridcraft_formula::Expr::Call(name, args) = e
&& let Some(spec) = gridcraft_functions::lookup(name)
&& !spec.accepts(args.len())
{
let few = args.len() < spec.min_args;
bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature));
}
});
match bad {
Some(msg) => Err(EngineError::Other(msg)),
None => Ok(()),
}
}
fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
@@ -210,10 +233,17 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other(m));
}
let active_sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection.
let range = cx.sel.current();
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
// the cell; a cell elsewhere (or on another sheet) gets a one-cell array.
let selected = cx.sel.current();
let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) };
// A table header can't hold an array's value, and a table resizes under it.
if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) {
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
}
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
if let Some(f) = cell.formula.as_mut() {
std::sync::Arc::make_mut(f).array = Some(range);
@@ -578,6 +608,7 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
let d = s.doc_mut()?;
let mut label = String::new();
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
@@ -586,6 +617,7 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
d.wb = e.wb;
d.selection = e.selection;
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
d.revision += 1;
Ok(json!({"undone": label}))
@@ -595,6 +627,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
let d = s.doc_mut()?;
let mut label = String::new();
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
@@ -603,6 +636,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
d.wb = e.wb;
d.selection = e.selection;
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
d.revision += 1;
Ok(json!({"redone": label}))
@@ -972,7 +1006,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
e
};
let mut moved = Formula::from_expr(e);
moved.array = f.array;
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
return Some(std::sync::Arc::new(moved));
}
let moved =
@@ -1139,6 +1175,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
use gridcraft_formula::adjust::Edit;
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
// (a cut moves its arrays itself).
let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. });
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
@@ -1146,16 +1186,21 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
if keys.is_empty() {
continue;
}
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
let Some(sheet) = wb.sheet_mut(si) else { continue };
for (c, f) in keys {
let Some(expr) = f.expr() else { continue };
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
let text = gridcraft_formula::print(&new);
if text != f.text
let array = match f.array {
Some(a) if on_target => super::sheet::map_range(a, e),
a => a,
};
if (text != f.text || array != f.array)
&& let Some(cell) = sheet.cells.get_mut(c)
{
let mut nf = Formula::from_expr(new);
nf.array = f.array;
nf.array = array;
cell.formula = Some(std::sync::Arc::new(nf));
}
}
@@ -1554,6 +1599,22 @@ mod tests {
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
}
#[test]
fn an_array_entered_outside_the_selection_covers_only_its_cell() {
// Found by the command-sequence fuzzer: the array took the selection's range even with
// `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the
// selected cells were wiped.
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap();
s.execute("selection.set", json!({"range": "K20:K22"})).unwrap();
s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap();
assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap()));
assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep"));
}
#[test]
fn wildcards() {
assert!(wildcard("hello", "h*o"));
+71 -1
View File
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Chart Element",
["Chart Design", "Chart Layouts"],
None,
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth, on?: bool, position?: bottom|top|left|right}",
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
has_doc,
add_element
),
@@ -316,12 +316,68 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
s.smooth = on;
}
}
"trendline" => {
let only = u32_param(p, "series").map(|i| i as usize);
if only.is_some_and(|i| i >= c.series.len()) {
return Err(bad("chart.addElement", "no such series"));
}
let targets: Vec<usize> = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect();
if !on {
for i in targets {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.clear();
}
}
return Ok(());
}
let t = trendline_param(p)?;
let eligible: Vec<usize> =
targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect();
if eligible.is_empty() {
return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series"));
}
for i in eligible {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.push(t.clone());
}
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())
})
}
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
/// Element ▸ Trendline choices, plus the Format Trendline options).
fn trendline_param(p: &Json) -> Result<Trendline> {
let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
let mut t = Trendline {
forward: num("forward"),
backward: num("backward"),
display_equation: bool_param(p, "displayEquation").unwrap_or(false),
display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false),
..Trendline::default()
};
t.kind = match str_param(p, "type").unwrap_or("linear") {
"linear" => TrendlineKind::Linear,
// Excel's Linear Forecast: a linear trendline projected two periods ahead.
"linearForecast" => {
if t.forward == 0.0 {
t.forward = 2.0;
}
TrendlineKind::Linear
}
"exponential" => TrendlineKind::Exponential,
"logarithmic" => TrendlineKind::Logarithmic,
"power" => TrendlineKind::Power,
"polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8),
"movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)),
other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))),
};
Ok(t)
}
fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {
let n = u32_param(p, "layout").unwrap_or(1);
with_chart(s, p, |c| {
@@ -421,6 +477,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
with_chart(s, p, move |c| {
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
super::insert::scatter_x_from_first(c.kind, &mut c.series);
c.source = Some(source);
c.by_rows = by_rows;
}
@@ -651,6 +708,19 @@ fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true });
}
/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current
/// list when they swap in another snapshot of the workbook.
pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>) {
let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned();
let keep = find(from);
if keep == find(to) {
return;
}
let wb = std::sync::Arc::make_mut(to);
wb.names.retain(|n| n.name != WATCH_NAME);
wb.names.extend(keep);
}
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let mut views = stash_get(&d.wb, VIEWS_NAME);
+13 -8
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} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -55,7 +55,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({})));
return ok();
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
let imported =
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
@@ -64,8 +65,10 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
} else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination.
let path = str_param(p, "path").filter(|_| !imported).map(str::to_string);
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
// workbook decrypted with its password, so Save never replaces the encrypted original with
// an unprotected copy.
let path = str_param(p, "path").filter(|_| !imported && !encrypted).map(str::to_string);
// Replace an untouched blank Book1 like Excel does.
if s.documents().len() == 1
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
@@ -73,10 +76,12 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
s.close_document(0);
}
let i = s.add_document(DocState::new(wb, path, title));
if !warnings.is_empty() {
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
if warnings.len() > 5 {
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
// The decrypted-workbook notice is for the info bar, not a message box.
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
if !shown.is_empty() {
let mut message = shown.iter().take(5).copied().collect::<Vec<_>>().join("\n\n");
if shown.len() > 5 {
message.push_str(&format!("\n\n{} additional import warnings.", shown.len() - 5));
}
s.ui_requests.push(crate::UiRequest::Message(message));
}
+2
View File
@@ -358,6 +358,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
});
let q = gridcraft_formula::quote_sheet(&sheet_name);
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
@@ -371,6 +372,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
secondary: false,
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
smooth: false,
trendlines: Vec::new(),
})
.collect();
sheet.charts.push(Chart {
+17 -1
View File
@@ -561,8 +561,11 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how };
edit(s, |cx| {
let mut touched = Vec::new();
// Areas whose merging changes: a merged cell blocks a dynamic array's spill.
let mut areas: Vec<RangeRef> = ranges.clone();
for r in &ranges {
let sh = cx.sheet_mut(sheet)?;
areas.extend(sh.merges.iter().filter(|m| m.intersects(r)));
sh.merges.retain(|m| !m.intersects(r));
match how {
"unmerge" => {}
@@ -593,6 +596,17 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
}
}
}
// Spills over the areas, and blocked ones that could reach them, are evaluated again.
let sh = cx.sheet_mut(sheet)?;
for a in &areas {
touched.extend(sh.spill_ranges.iter().filter(|(_, r)| r.intersects(a)).map(|(anchor, _)| (sheet, *anchor)));
let reach = RangeRef::new(CellRef::new(0, 0), a.end);
let blocked = sh
.cells
.iter_range(reach)
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
touched.extend(blocked.map(|(c, _)| (sheet, c)));
}
cx.changed.extend(touched);
Ok(())
})?;
@@ -1019,7 +1033,9 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let st = cx.wb.styles.get(sh.style_id(cell));
let text = crate::display::cell_text(&cx.wb, sh, cell);
for line in text.lines() {
max = max.max(approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0);
max = max.max(
approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell),
);
}
}
if max > 0.0 {
+31 -16
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Calculation Options",
["Formulas", "Calculation"],
None,
"{mode: automatic|automaticExceptTables|manual, iterative?, maxIterations?, maxChange?}",
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
has_doc,
calc_options
),
@@ -78,18 +78,28 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
let num_end = sel.end;
let below = CellRef::new(num_end.row + 1, 0);
let mut writes = Vec::new();
let last_row_blank = (sel.start.col..=sel.end.col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, below.row) };
for c in sel.start.col..=sel.end.col {
let r = RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c));
writes.push((CellRef::new(out_row, c), format!("={func}({})", r.a1())));
}
for (c, f) in writes {
s.execute("cell.set", json!({"cell": c.a1(), "input": f}))?;
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
@@ -554,6 +564,7 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
_ => None,
};
edit(s, |cx| {
let before = cx.wb.calc.clone();
if let Some(m) = mode {
cx.wb.calc.mode = m;
}
@@ -561,15 +572,19 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
cx.wb.calc.iterative = v;
}
if let Some(v) = u32_param(p, "maxIterations") {
cx.wb.calc.max_iterations = v.clamp(1, 32767);
cx.wb.calc.set_max_iterations(v);
}
if let Some(v) = f64_param(p, "maxChange") {
cx.wb.calc.max_change = v.abs();
cx.wb.calc.set_max_change(v);
}
if mode == Some(CalcMode::Automatic) {
let c = &cx.wb.calc;
// Switching to automatic, or changing how circular references are calculated outside
// manual mode, recalculates the workbook.
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
cx.structural = true;
}
Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)}))
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
})
}
+46 -4
View File
@@ -1,7 +1,7 @@
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
//! conditional formatting (Home › Styles) and symbols.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
@@ -224,6 +224,12 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
return Err(EngineError::Other("A table can't overlap another table.".into()));
}
let arrays = |sh: &gridcraft_model::Sheet| {
sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r)))
};
if d.wb.sheet(sheet).is_some_and(arrays) {
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
}
if r.count() > 50_000_000 {
return Err(bad("insert.table", "range too large"));
}
@@ -234,9 +240,19 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name")
.map(str::to_string)
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
// Without a header row, the data moves down a row to make room for one: never a table with it.
let moves_a_table = |sh: &gridcraft_model::Sheet| {
sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col)
};
if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) {
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
}
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
let id = d.wb.next_object_id();
edit(s, |cx| {
@@ -245,6 +261,11 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
r
} else {
let sh = cx.sheet_mut(sheet)?;
// The header row pushes the data down one row: never off the end of the sheet.
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
}
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
.into_iter()
@@ -257,7 +278,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
}
cx.structural = true;
RangeRef::new(r.start, CellRef::new(r.end.row + 1, r.end.col))
RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col))
};
let sh = cx.sheet_mut(sheet)?;
let mut names: Vec<String> = Vec::new();
@@ -296,6 +317,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
});
cx.structural = true;
Ok(json!({"table": name, "range": r.a1()}))
@@ -355,7 +377,12 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols),
"firstCol" => t.first_col = on.unwrap_or(!t.first_col),
"lastCol" => t.last_col = on.unwrap_or(!t.last_col),
"header" => t.filter_button = on.unwrap_or(!t.filter_button),
"header" => {
t.filter_button = on.unwrap_or(!t.filter_button);
if !t.filter_button {
t.filter.clear();
}
}
_ => {}
}
Ok(Json::Null)
@@ -553,6 +580,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
});
}
} else {
@@ -567,12 +595,24 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
});
}
}
out
}
/// A scatter plot of a block without category labels takes its first column (or row) as the X
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() {
let x = series.remove(0).values;
for se in series.iter_mut() {
se.categories = Some(x.clone());
}
}
}
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
@@ -661,10 +701,11 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let data_w = r.width();
let by_rows = data_w > data_h + 1;
let kind = chart_kind(&t, &sub);
let series = series_from_range(sh, r, by_rows);
let mut series = series_from_range(sh, r, by_rows);
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
scatter_x_from_first(kind, &mut series);
let id = d.wb.next_object_id();
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
let title = str_param(p, "title").map(str::to_string).or_else(|| {
@@ -801,6 +842,7 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
c.source.get_or_insert(src);
c.by_rows = by_rows;
c.series = series;
scatter_x_from_first(c.kind, &mut c.series);
}
Ok(Json::Null)
})
+1 -1
View File
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"freeze": sh.freeze,
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len()})).collect::<Vec<_>>(),
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"images": sh.images.len(),
"shapes": sh.shapes.len(),
"conditionalFormats": sh.cond_formats.len(),
+69 -1
View File
@@ -149,7 +149,42 @@ pub fn command_specs() -> &'static [CommandSpec] {
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
if let Some(spec) = command_specs().iter().find(|c| c.id == id) {
return Some(spec);
}
// Commands registered by UI integration tests (pending keytips go live
// the moment their command lands, with no table edit).
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
/// Not part of the public API; harmless no-op for production callers who never call it.
#[doc(hidden)]
pub fn register_test_command(id: &'static str, run: Run) {
// Leaked intentionally: test commands live for the whole process.
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id,
label: id,
menu: &[],
shortcut: None,
params: "",
enabled: |_| Ok(()),
run,
journal: true,
undoable: false,
}));
test_commands().push(spec);
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
// ---------------------------------------------------------------- param helpers
@@ -302,6 +337,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if !structural {
check_arrays(&d.wb, &wb)?;
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
@@ -372,6 +410,36 @@ pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocke
})
}
/// Refuses an edit that put something into a cell of a multi-cell legacy array other than its
/// formula cell, while the array stays (Excel: "You can't change part of an array."). The array
/// would show its value there while the file kept the new content. Cells shifted by row and
/// column edits aren't edits of their content, so structural commits aren't checked.
fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
for sh in &old.sheets {
if sh.spill.is_empty() {
continue;
}
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
if std::sync::Arc::ptr_eq(sh, after) {
continue;
}
for c in sh.cells.diff(&after.cells) {
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
continue;
}
let in_array = sh.spill_ranges.keys().any(|&anchor| {
anchor != c
&& after.cell(anchor).and_then(|x| x.formula.as_ref()).and_then(|f| f.array).is_some_and(|a| a.start == anchor && a.contains(c))
});
if in_array {
return Err(EngineError::Other("You can't change part of an array.".into()));
}
}
}
Ok(())
}
/// Two cells hold the same value or formula (formats aside).
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
+88 -25
View File
@@ -167,27 +167,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
sh.validations.retain(|dv| !dv.ranges.is_empty());
for t in sh.tables.iter_mut() {
if let Some(r) = map_range(t.range) {
// Columns inserted inside a table become new table columns.
if axis == Axis::Cols && insert && at > t.range.start.col && at <= t.range.end.col {
let idx = (at - t.range.start.col) as usize;
for k in 0..count {
let name = format!("Column{}", t.columns.len() + 1);
t.columns.insert(
(idx + k as usize).min(t.columns.len()),
gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None },
);
}
}
if axis == Axis::Cols && !insert {
let lo = at.max(t.range.start.col);
let hi = (at + count).min(t.range.end.col + 1);
if hi > lo {
let a = (lo - t.range.start.col) as usize;
let b = (hi - t.range.start.col) as usize;
if b <= t.columns.len() {
t.columns.drain(a..b);
}
}
if axis == Axis::Cols {
shift_table_columns(t, at, count, insert);
}
t.range = r;
} else {
@@ -444,6 +425,9 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
let sheet = target_sheet(s, p)?;
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
check_table_shift(sh, &e)?;
}
let right = shift == "right";
let out = edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
@@ -472,6 +456,9 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
let sheet = target_sheet(s, p)?;
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
check_table_shift(sh, &e)?;
}
let left = shift == "left";
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
@@ -798,8 +785,16 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if copy {
let mut sh = (*src).clone();
let base = sh.name.clone();
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
sh.name = name.chars().take(31).collect();
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
let name = (2..)
.map(|k| {
let suffix = format!(" ({k})");
let keep = 31usize.saturating_sub(suffix.chars().count());
format!("{}{suffix}", base.chars().take(keep).collect::<String>().trim_end())
})
.find(|nm| cx.wb.sheet_index(nm).is_none())
.unwrap_or(base.clone());
sh.name = name;
sh.tables.clear(); // table names must be unique
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
let at = to.min(n);
@@ -910,7 +905,7 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
}
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
sheet: gridcraft_formula::SheetSel::Current,
kind: gridcraft_formula::RefKind::Range(
@@ -942,9 +937,16 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
}
sh.validations.retain(|dv| !dv.ranges.is_empty());
for t in sh.tables.iter_mut() {
// Cells inserted or deleted across a table's full height add or remove table columns.
if let Edit::InsertCells { axis: Axis::Cols, range } | Edit::DeleteCells { axis: Axis::Cols, range } = *e
&& range.start.row <= t.range.start.row
&& range.end.row >= t.range.end.row
{
shift_table_columns(t, range.start.col, range.width(), matches!(e, Edit::InsertCells { .. }));
}
t.range = map_range(t.range, e).unwrap_or_default();
}
sh.tables.retain(|t| t.range != RangeRef::default());
sh.tables.retain(|t| t.range != RangeRef::default() && !t.columns.is_empty());
if let Some(af) = sh.autofilter.as_mut() {
match map_range(af.range, e) {
Some(r) => af.range = r,
@@ -964,3 +966,64 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
sh.print.print_area = map_range(pa, e);
}
}
/// Columns inserted inside a table become new table columns (with names no other column has);
/// deleted ones leave it.
fn shift_table_columns(t: &mut gridcraft_model::Table, at: u32, count: u32, insert: bool) {
if insert {
if at > t.range.start.col && at <= t.range.end.col {
let idx = (at - t.range.start.col) as usize;
let mut n = t.columns.len();
for k in 0..count as usize {
let name = loop {
n += 1;
let name = format!("Column{n}");
if !t.columns.iter().any(|c| c.name.eq_ignore_ascii_case(&name)) {
break name;
}
};
let col = gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None };
t.columns.insert((idx + k).min(t.columns.len()), col);
}
}
return;
}
let lo = at.max(t.range.start.col);
let hi = at.saturating_add(count).min(t.range.end.col + 1);
if hi > lo {
let a = (lo - t.range.start.col) as usize;
let b = (hi - t.range.start.col) as usize;
if b <= t.columns.len() {
t.columns.drain(a..b);
}
}
}
/// Like Excel, Insert/Delete Cells never shifts part of a table: cells shifted up or down must
/// hold whole table columns, cells shifted left or right whole table rows, and deleting cells
/// never takes a table's header row without the whole table.
pub(crate) fn check_table_shift(sh: &Sheet, e: &Edit) -> Result<()> {
let (axis, r, delete) = match *e {
Edit::InsertCells { axis, range } => (axis, range, false),
Edit::DeleteCells { axis, range } => (axis, range, true),
_ => return Ok(()),
};
for t in &sh.tables {
let tr = t.range;
let ok = match axis {
Axis::Rows => {
let moves = tr.end.row >= r.start.row && tr.start.col <= r.end.col && tr.end.col >= r.start.col;
let header_alone = delete && t.header_row && r.start.row <= tr.start.row && r.end.row >= tr.start.row && r.end.row < tr.end.row;
!moves || (r.start.col <= tr.start.col && r.end.col >= tr.end.col && !header_alone)
}
Axis::Cols => {
let moves = tr.end.col >= r.start.col && tr.start.row <= r.end.row && tr.end.row >= r.start.row;
!moves || (r.start.row <= tr.start.row && r.end.row >= tr.end.row)
}
};
if !ok {
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
}
}
Ok(())
}
+7 -2
View File
@@ -80,15 +80,20 @@ pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
/// The text a cell shows (full precision General; the grid narrows General to fit the column).
pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let v = sheet.value(c);
if sheet.show_formulas
&& let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
&& !formula_hidden(wb, sheet, c)
{
return format!("={}", f.text);
}
value_text(wb, sheet, c)
}
/// A cell's value with its number format applied, independent of column width: no `#####`
/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result.
pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
let style = wb.styles.get(sheet.style_id(c));
let f = format(&v, style.num_fmt.as_str(), wb);
let f = format(&sheet.value(c), style.num_fmt.as_str(), wb);
match f.fill {
Some((_, pos)) => {
let mut t = f.text;
+202 -19
View File
@@ -38,16 +38,56 @@ impl FileKind {
}
}
/// The error for an encrypted workbook opened without a password contains this. The interface
/// asks for the password when it sees it; programmatic callers pass `password` instead.
pub const PASSWORD_REQUIRED: &str = "is password-protected. Enter its password to open it.";
/// The error for a wrong password contains this.
pub const WRONG_PASSWORD: &str = "The password is incorrect.";
/// The first open warning of a workbook decrypted with its password.
pub const DECRYPTED_NOTICE: &str = "This workbook was password-protected. GridCraft saves it without the password.";
/// Parses a file's bytes into a workbook. `name` picks the format by extension when the bytes
/// don't say.
/// don't say. An encrypted workbook needs [`open_bytes_with_password`].
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
open_bytes_with_password(name, bytes, None)
}
/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never
/// quote the password.
pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec<String>)> {
if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted {
return open_plain(name, bytes);
}
// A scheme no password opens is said straight away, rather than after asking for one.
let decrypt_error = |e: gridcraft_xlsx::DecryptError| EngineError::Other(decrypt_message(name, e));
let Some(password) = password else {
gridcraft_xlsx::check_encryption(bytes).map_err(decrypt_error)?;
return Err(EngineError::Other(format!("'{name}' {PASSWORD_REQUIRED}")));
};
let plain = gridcraft_xlsx::decrypt(bytes, password).map_err(decrypt_error)?;
if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
}
let (wb, mut warnings) = open_plain(name, &plain)?;
warnings.insert(0, DECRYPTED_NOTICE.into());
Ok((wb, warnings))
}
/// What to tell the user when an encrypted workbook can't be decrypted. Never quotes the password.
fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
match e {
gridcraft_xlsx::DecryptError::WrongPassword => format!("{WRONG_PASSWORD} Check that Caps Lock is off and try again."),
gridcraft_xlsx::DecryptError::Unsupported(scheme) => {
format!("'{name}' uses {scheme} encryption, which GridCraft can't open yet. Remove the password in Excel and try again.")
}
gridcraft_xlsx::DecryptError::Malformed(detail) => format!("We can't open '{name}': the encrypted file is damaged ({detail})."),
}
}
/// Parses bytes that aren't encrypted.
fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!(
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
)));
}
if sniffed == gridcraft_xlsx::Format::Xls {
let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
@@ -85,6 +125,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
}
@@ -113,26 +155,40 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
// A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm
// keeps the macros; any other name saves without them, as Excel does.
FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => {
let mut plain = wb.clone();
plain.vba_project = None;
gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Html => Ok(to_html(wb, sheet).into_bytes()),
}
}
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
/// transferred to other file formats*): number formats applied, never column-width `#####`.
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
gridcraft_xlsx::write_csv(sh, delimiter, |c| {
// General's grid-width formatting rounds large identifiers to scientific notation.
// Delimited files have no column width, so preserve the whole numeric value instead.
if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General")
&& let gridcraft_core::Value::Number(number) = sh.value(c)
&& number.is_finite()
{
number.to_string()
} else {
crate::display::value_text(wb, sh, c)
}
})
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
@@ -384,6 +440,8 @@ mod tests {
assert!(has_macros("book.xlsm"));
assert!(has_macros("BOOK.XLSM"));
assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project");
assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project");
assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project");
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
@@ -410,12 +468,71 @@ mod tests {
b
}
/// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last
/// two columns, a part name containing a comma.
const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\
1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n";
#[test]
fn csv_save_keeps_the_separator_it_was_opened_with() {
let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap();
assert_eq!(wb.csv_delimiter, Some(b';'));
assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST);
// Save As another .csv name keeps it too; a comma file stays a comma file (quoting only
// fields that contain the separator in use).
assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST);
let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n";
let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap();
assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas);
// A tab-separated file named .csv round-trips with tabs.
let tabs = "a\tb\r\n1\t2\r\n";
let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap();
assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs);
}
#[test]
fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a"));
sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0));
}
assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n");
// Through .xlsx the CSV origin is gone, as in Excel.
let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap();
let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap();
assert_eq!(reopened.csv_delimiter, None);
assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,"));
// A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs.
let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap();
assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n");
assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n");
}
#[test]
fn encrypted_workbook_is_reported_not_misread() {
let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("password-protected"), "got: {msg}");
let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw");
let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string();
assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}");
assert!(!msg.contains("zip"), "must not surface the zip error: {msg}");
// A compound file naming the streams but unreadable is damage: no password is asked for,
// and none helps.
let file = cfb(&["EncryptionInfo", "EncryptedPackage"]);
for password in [None, Some("pw")] {
let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string();
assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}");
}
}
#[test]
fn encryption_no_password_opens_is_reported_without_asking_for_one() {
let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES
let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]);
let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string();
assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}");
let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]);
let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string();
assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}");
}
#[test]
@@ -425,4 +542,70 @@ mod tests {
assert!(msg.contains("not an Excel workbook"), "got: {msg}");
assert!(!msg.contains("password"), "got: {msg}");
}
/// Save As CSV/TSV writes cells as displayed: number formats applied, formula results,
/// whole values in narrow columns (never `#####`), quoting unchanged.
#[test]
fn csv_writes_displayed_text() {
use gridcraft_core::{CellError, Value};
use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId};
let mut wb = Workbook::new();
let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code));
let (date, money, pct, frac, text, fixed, thousands) =
(styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0"));
let sh = wb.sheet_mut(0).unwrap();
let cells = [
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }),
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }),
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }),
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }),
("E1", Cell { value: Value::text("007"), formula: None, style: text }),
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }),
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }),
("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }),
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }),
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }),
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }),
];
for (a, c) in cells {
sh.set_cell(CellRef::parse(a).unwrap(), c);
}
// Column B is far too narrow for its numbers: the grid shows #####, the file must not.
sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() });
sh.show_formulas = true;
let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap();
assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n");
let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap();
assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}");
}
#[test]
fn delimited_exports_preserve_general_precision_and_explicit_number_formats() {
use gridcraft_core::Value;
use gridcraft_model::{Cell, NumFmt, StyleId};
let mut workbook = Workbook::new();
let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00"));
let sheet = workbook.sheet_mut(0).unwrap();
sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() });
sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() });
sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() });
for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] {
let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap();
assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n"));
}
}
#[test]
fn template_extension_saves_template_content_type() {
let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut types = String::new();
std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap();
assert!(
types.contains(
r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""#
),
"{types}"
);
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ use gridcraft_model::Workbook;
use serde::Serialize;
use serde_json::Value as Json;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_test_command};
pub use gridcraft_calc as calc;
pub use gridcraft_core as core;
pub use gridcraft_formula as formula;
+1
View File
@@ -1397,6 +1397,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
})
})
.collect()
+1
View File
@@ -109,6 +109,7 @@ fn sales(s: &mut Session) -> bool {
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
}];
}
}
+9 -2
View File
@@ -176,8 +176,15 @@ pub(crate) fn sync_headers(cx: &mut crate::cmd::Ctx) {
let Some(t) = sh.tables.get(ti) else { continue };
let col = (c.col - t.range.start.col) as usize;
let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
let text = crate::display::cell_text(&cx.wb, sh, c);
let is_text = sh.value(c).is_text();
// A text header names its column as it is: a number format only changes how it looks (an
// accounting format pads text with spaces), and the file writes the name into the cell.
let value = sh.value(c);
let text = match value.as_text() {
Some(t) => t.to_string(),
None => crate::display::cell_text(&cx.wb, sh, c),
};
// Headers hold constant text: a formula (or a spilled value) there becomes its text.
let is_text = value.is_text() && sh.cell(c).is_some_and(|x| x.formula.is_none());
if text == old && is_text {
continue;
}
+435
View File
@@ -202,6 +202,45 @@ fn enter_values_and_formulas() {
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "12%");
}
#[test]
fn formulas_with_the_wrong_argument_count_are_refused_on_entry() {
// #363: =DATEDIF(start, end) was stored and showed #VALUE!. Like Excel, entry refuses it and
// the message gives the signature, so the missing unit argument is visible.
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "kept"})).unwrap();
for (input, word) in [
("=DATEDIF(DATE(2024,1,1),DATE(2024,3,15))", "few"),
("=1+DATE(2024,1)", "few"),
("=SUM()", "few"),
("=IFERROR(1,LEFT(\"a\",1,2,3))", "many"),
("=datedif(1,2,\"D\",4)", "many"),
] {
let e = s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap_err().to_string();
assert!(e.contains(&format!("too {word} arguments")), "{input}: {e}");
assert_eq!(v(&s, "A1"), Value::text("kept"), "{input} must not replace the cell");
}
let e = s.execute("cell.set", json!({"cell": "A1", "input": "=DATEDIF(1,2)"})).unwrap_err().to_string();
assert!(e.contains("DATEDIF(start_date, end_date, unit)"), "{e}");
// Correct counts, optional arguments left empty, special forms, LAMBDA names and unknown
// functions (#NAME?) still enter.
s.execute("formulas.defineName", json!({"name": "TWICE", "refersTo": "=LAMBDA(x,x*2)"})).unwrap();
for (cell, input) in [
("B1", "=DATEDIF(DATE(2024,1,1),DATE(2024,3,15),\"D\")"),
("B2", "=IF(TRUE,1)"),
("B3", "=LET(a,1,b,2,a+b)"),
("B4", "=TWICE(4)"),
("B5", "=NOSUCHFUNCTION(1,2,3)"),
("B6", "=ROUND(2.5,)"),
] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap_or_else(|e| panic!("{input}: {e}"));
}
assert_eq!(v(&s, "B1"), Value::Number(74.0));
assert_eq!(v(&s, "B4"), Value::Number(8.0));
// Pasted text that isn't a valid formula stays text, as it does for a parse error.
s.execute("edit.paste", json!({"at": "C1", "text": "=DATEDIF(1,2)\n"})).unwrap();
assert_eq!(v(&s, "C1"), Value::text("=DATEDIF(1,2)"));
}
#[test]
fn undo_redo() {
let mut s = s();
@@ -1032,6 +1071,202 @@ fn slicer_filters_table() {
assert!(s.execute("slicer.toggle", json!({"value": "Nowhere"})).is_err());
}
#[test]
fn table_header_formatted_as_accounting_keeps_its_text_as_the_column_name() {
// Found by the command-sequence fuzzer: the accounting format pads text (` 172.4 `), and that
// padded text became the column name, which the XLSX writer then put in the header cell.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[172.4, "Qty"], [1, 2]]})).unwrap();
s.execute("home.accounting", json!({"range": "A1:B2"})).unwrap();
s.execute("insert.table", json!({"range": "A1:B2", "header": true})).unwrap();
let d = s.doc().unwrap();
let t = &d.wb.sheet(0).unwrap().tables[0];
let names: Vec<_> = t.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["172.4", "Qty"]);
assert_eq!(v(&s, "A1"), Value::text("172.4"));
reopen(&mut s);
assert_eq!(v(&s, "A1"), Value::text("172.4"));
}
#[test]
fn a_formula_moved_into_a_table_header_becomes_its_text() {
// Found by the command-sequence fuzzer: deleting a header row moved a formula into the
// header, which kept it while the XLSX writer wrote the column name there instead.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty"], ["x", "=1+1"], ["y", 5]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
assert_eq!(sh.cell(CellRef::new(0, 1)).and_then(|x| x.formula.clone()), None);
assert_eq!(v(&s, "B1"), Value::text("2"));
let names: Vec<_> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["x", "2"]);
}
#[test]
fn a_whole_column_table_without_headers_stays_on_the_sheet() {
// Found by the command-sequence fuzzer: the inserted header row pushed the table's end past
// the last row, so the table had an invalid range and was dropped from the XLSX file.
let mut s = s();
s.execute("insert.table", json!({"range": "A:C", "header": false})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.wb.sheet(0).unwrap().tables[0].range.a1(), "A:C");
s.execute("cell.set", json!({"cell": "E1", "input": "=ROWS(Table1)"})).unwrap();
assert_eq!(v(&s, "E1"), Value::Number(1_048_575.0));
reopen(&mut s);
assert_eq!(v(&s, "E1"), Value::Number(1_048_575.0));
s.execute("cell.set", json!({"cell": "F1048576", "input": "x"})).unwrap();
let e = s.execute("insert.table", json!({"range": "F:F", "header": false})).unwrap_err();
assert!(e.to_string().contains("pushed off the end"), "{e}");
}
#[test]
fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
// Found by the command-sequence fuzzer: AutoSum over rows `3:5` ran one `cell.set` per column
// (16,384 undo steps, so undo never got back to the start).
let mut s = s();
s.execute("range.setValues", json!({"range": "A3", "values": [[1, 2], [3, 4], [5, 6]]})).unwrap();
s.execute("formulas.autoSum", json!({"range": "3:5"})).unwrap();
assert_eq!(v(&s, "A6"), Value::Number(9.0));
assert_eq!(v(&s, "B6"), Value::Number(12.0));
let d = s.doc().unwrap();
assert_eq!(d.undo.len(), 2);
assert_eq!(d.wb.active().unwrap().cells.used_range().unwrap().a1(), "A3:B6");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A6"), Value::Empty);
assert_eq!(v(&s, "B6"), Value::Empty);
}
#[test]
fn insert_and_delete_cells_never_shift_part_of_a_table() {
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
// tables overlapped) or narrowed a table without dropping its columns, and a table without
// headers pushed the cells of another table down from under it.
let mut s = s();
s.execute("range.setValues", json!({"range": "D8", "values": [["a", "b", "c"], [1, 2, 3], [4, 5, 6]]})).unwrap();
s.execute("insert.table", json!({"range": "D8:F10", "header": true})).unwrap();
for (cmd, range, shift) in [
("home.deleteCells", "E9:G9", "left"),
("home.deleteCells", "E9", "up"),
("home.insertCells", "C9:D9", "down"),
("home.deleteCells", "D8:F8", "up"),
("home.insertCells", "A9:B9", "right"),
] {
let e = s.execute(cmd, json!({"range": range, "shift": shift})).unwrap_err();
assert!(e.to_string().contains("shift cells in a table"), "{cmd} {range} {shift}: {e}");
}
// Whole table columns: the table loses (or gains) columns with them.
s.execute("home.deleteCells", json!({"range": "E7:E12", "shift": "left"})).unwrap();
let names = |s: &Session| -> Vec<String> { s.doc().unwrap().wb.sheet(0).unwrap().tables[0].columns.iter().map(|c| c.name.clone()).collect() };
assert_eq!(names(&s), ["a", "c"]);
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().tables[0].range.a1(), "D8:E10");
s.execute("home.insertCells", json!({"range": "E8:E10", "shift": "right"})).unwrap();
assert_eq!(names(&s), ["a", "Column2", "c"]);
assert_eq!(v(&s, "E8"), Value::text("Column2"));
let e = s.execute("insert.table", json!({"range": "H1:H2", "name": "T1"})).unwrap_err();
assert!(e.to_string().contains("not valid"), "a cell reference isn't a table name: {e}");
let e = s.execute("insert.table", json!({"range": "D5:F6", "header": false})).unwrap_err();
assert!(e.to_string().contains("shift cells in a table"), "{e}");
}
#[test]
fn undo_and_redo_keep_the_watch_window_list() {
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
// undoing an earlier edit swapped in a snapshot without it.
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("formulas.watchWindow", json!({"add": "Sheet1!A1"})).unwrap();
let watched = |s: &mut Session| -> Vec<String> {
let rows = s.execute("formulas.watchWindow", json!({})).unwrap();
rows.as_array().unwrap().iter().map(|r| r["cell"].as_str().unwrap().to_string()).collect()
};
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(watched(&mut s), ["Sheet1!A1"], "kept after undo");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(watched(&mut s), ["Sheet1!A1"], "kept after redo");
}
#[test]
fn merging_over_a_spill_blocks_it_and_unmerging_unblocks_it() {
// Found by the command-sequence fuzzer: merges changed no cell the spill depended on, so the
// spill went on as if the merged cells were free (a full recalc gave #SPILL!).
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
s.execute("home.mergeCenter", json!({"range": "A2:B3"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill));
s.execute("home.mergeCenter", json!({"range": "A2:B3"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(1.0));
assert_eq!(v(&s, "A3"), Value::Number(3.0));
}
#[test]
fn multi_cell_array_formulas_and_tables_dont_overlap() {
// Found by the command-sequence fuzzer: an array over a table header lost its value to the
// header name written as text, then showed #SPILL! (and reloaded from XLSX differently).
let mut s = s();
s.execute("range.setValues", json!({"range": "B2", "values": [["Item", "Qty"], ["x", 2]]})).unwrap();
s.execute("insert.table", json!({"range": "B2:C3", "header": true})).unwrap();
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
let e = s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap_err();
assert!(e.to_string().contains("aren't allowed in tables"), "{e}");
assert_eq!(v(&s, "B2"), Value::text("Item"));
s.execute("selection.set", json!({"range": "E1:E2"})).unwrap();
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
let e = s.execute("insert.table", json!({"range": "E2:F3"})).unwrap_err();
assert!(e.to_string().contains("aren't allowed in tables"), "{e}");
// One-cell arrays are fine.
s.execute("selection.set", json!({"range": "C3"})).unwrap();
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
assert_eq!(v(&s, "C3"), Value::Number(5.0));
}
#[test]
fn copies_of_a_long_named_sheet_get_unique_names() {
// Found by the command-sequence fuzzer: `name (2)` was cut to 31 characters, so the second
// copy of a 30-character name took the first copy's name.
let mut s = s();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Long name of a sheet 31 chars!"})).unwrap();
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
s.execute("sheet.move", json!({"sheet": 0, "to": 2, "copy": true})).unwrap();
let d = s.doc().unwrap();
let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["Long name of a sheet 31 chars!", "Long name of a sheet 31 cha (2)", "Long name of a sheet 31 cha (3)"]);
}
#[test]
fn row_and_column_inserts_move_legacy_array_ranges() {
// Found by the command-sequence fuzzer: the formula cell moved but its array range stayed,
// so the array covered the wrong cells (and read back from XLSX as #SPILL!).
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
s.execute("selection.set", json!({"range": "C3:D3"})).unwrap();
s.execute("cell.set", json!({"input": "=$A$1*2", "array": true})).unwrap();
s.execute("home.insertColumns", json!({"cols": "B:B"})).unwrap();
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
let d = s.doc().unwrap();
let f = d.wb.sheet(0).unwrap().cell(CellRef::new(3, 3)).and_then(|x| x.formula.clone()).unwrap();
assert_eq!(f.array, Some(gridcraft_core::RangeRef::parse("D4:E4").unwrap()));
assert_eq!(v(&s, "E4"), Value::Number(14.0));
reopen(&mut s);
assert_eq!(v(&s, "E4"), Value::Number(14.0));
}
#[test]
fn part_of_a_legacy_array_cant_be_changed() {
// Found by the command-sequence fuzzer: a value typed into an array cell showed in the
// workbook, but the file kept the array there (Excel refuses the edit).
let mut s = s();
s.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
let e = s.execute("range.setValues", json!({"range": "A2", "values": [[13]]})).unwrap_err();
assert!(e.to_string().contains("part of an array"), "{e}");
assert_eq!(v(&s, "A2"), Value::Number(5.0));
// Replacing the whole array is fine.
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
assert_eq!(v(&s, "A2"), Value::Number(2.0));
}
#[test]
fn create_names_from_selection() {
let names = |s: &Session| -> Vec<(String, String)> {
@@ -1257,6 +1492,33 @@ fn ods_import_never_reuses_source_as_save_target() {
std::fs::remove_dir_all(dir).unwrap();
}
/// Open a `;` CSV by path (a double-click), edit, Save: the file keeps its `;` separator.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn saving_an_opened_semicolon_csv_keeps_semicolons() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-csv-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("cut list.csv");
std::fs::write(&path, "1;511;184;P3_L1_led;2162PE;16;;1244121406FB\r\n1;562;510;P3_pod;2162PE;19;;124412140710\r\n").unwrap();
let mut s = s();
s.execute("file.open", json!({"path": path})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "600"})).unwrap();
s.execute("file.save", json!({})).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"1;511;184;P3_L1_led;2162PE;16;;1244121406FB\r\n1;600;510;P3_pod;2162PE;19;;124412140710\r\n"
);
let exported = dir.join("export.csv");
s.execute("file.exportCsv", json!({"path": exported})).unwrap();
assert!(std::fs::read_to_string(&exported).unwrap().starts_with("1;511;184;"));
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
let bytes = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"1;511;184;"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validation_formulas_are_relative_and_see_the_new_value() {
let mut s = s();
@@ -1508,6 +1770,44 @@ fn reopen(s: &mut Session) {
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
}
#[test]
fn calculation_options_enable_iterative_calculation() {
let mut s = s();
let near = |s: &Session, a: &str, want: f64| {
let got = v(s, a).as_f64().unwrap_or(f64::NAN);
assert!((got - want).abs() < 0.002, "{a} = {:?}", v(s, a));
};
s.execute("cell.set", json!({"cell": "A1", "input": "=(A2+10)/2"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "=A1"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
assert!(!s.doc().unwrap().calc.circular.is_empty());
// Switching it on recalculates the loop.
let r = s.execute("formulas.calculationOptions", json!({"iterative": true})).unwrap();
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001}));
near(&s, "A1", 10.0);
near(&s, "A2", 10.0);
assert!(s.doc().unwrap().calc.circular.is_empty());
// Empty params change nothing and report the settings.
let r = s.execute("formulas.calculationOptions", json!({})).unwrap();
assert_eq!(r["iterative"], true);
// Limits are kept within Excel's: 1–32767 iterations, a non-negative change.
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 0, "maxChange": -0.5})).unwrap();
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(1), Some(0.5)));
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 100000, "maxChange": 0.0001})).unwrap();
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(32767), Some(0.0001)));
// Saved with the workbook.
reopen(&mut s);
let calc = s.doc().unwrap().wb.calc.clone();
assert_eq!((calc.iterative, calc.max_iterations, calc.max_change), (true, 32767, 0.0001));
near(&s, "A1", 10.0);
// Undo brings the previous settings back.
s.execute("formulas.calculationOptions", json!({"iterative": false})).unwrap();
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.calc.iterative);
near(&s, "A1", 10.0);
}
fn chart_data(s: &mut Session) {
s.execute(
"range.setValues",
@@ -1554,6 +1854,31 @@ fn every_chart_kind_survives_xlsx() {
}
}
#[test]
fn scatter_of_two_columns_is_one_series_with_the_first_as_x() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["X", "Y"], [1, 10], [2, 20], [3, 30]]})).unwrap();
s.execute("insert.chart", json!({"type": "scatter", "range": "A1:B4"})).unwrap();
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
assert_eq!(c.series.len(), 1);
assert_eq!(c.series[0].values, "Sheet1!$B$2:$B$4");
assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
// Select Data on the scatter chart and Switch Row/Column rebuild the series the same way.
s.execute("chart.selectData", json!({"range": "A2:B4"})).unwrap();
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
assert_eq!(c.series.len(), 1);
assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2, 3, 4], [10, 20, 30, 40]]})).unwrap();
s.execute("chart.selectData", json!({"range": "D1:G2"})).unwrap();
s.execute("chart.switchRowColumn", json!({})).unwrap();
s.execute("chart.switchRowColumn", json!({})).unwrap();
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
assert!(c.by_rows);
assert_eq!(c.series.len(), 1);
assert_eq!(c.series[0].values, "Sheet1!$D$2:$G$2");
assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$D$1:$G$1"));
}
#[test]
fn whole_sheet_selection_stays_within_the_data() {
// On an empty sheet with everything selected these filled or scanned every cell of the
@@ -1608,6 +1933,45 @@ fn switch_row_column_on_a_chart_read_from_a_file() {
assert_eq!(chart(&s).series.len(), 4);
}
#[test]
fn chart_trendlines_are_added_saved_and_removed() {
use gridcraft_model::TrendlineKind;
let mut s = s();
chart_data(&mut s);
s.execute("insert.chart", json!({"type": "line", "range": "A1:E7"})).unwrap();
let chart = |s: &Session| s.doc().unwrap().wb.active().unwrap().charts[0].clone();
s.execute("chart.addElement", json!({"element": "trendline", "series": 1, "type": "linearForecast", "displayEquation": true})).unwrap();
s.execute("chart.addElement", json!({"element": "trendline", "series": 1, "type": "polynomial", "order": 9})).unwrap();
s.execute("chart.addElement", json!({"element": "trendline", "series": 3, "type": "movingAverage", "period": 3})).unwrap();
let c = chart(&s);
assert!(c.series[0].trendlines.is_empty());
let t = &c.series[1].trendlines;
assert_eq!((t.len(), t[0].kind, t[0].forward, t[0].display_equation), (2, TrendlineKind::Linear, 2.0, true));
assert_eq!(t[1].kind, TrendlineKind::Polynomial(6), "order clamps to Excel's 2..6");
assert_eq!(c.series[3].trendlines[0].kind, TrendlineKind::MovingAverage(3));
let info = s.execute("document.inspect", json!({})).unwrap();
assert_eq!(info["sheets"][0]["charts"][0]["trendlines"], json!(["Linear", "Polynomial(6)", "MovingAverage(3)"]));
assert!(s.execute("chart.addElement", json!({"element": "trendline", "series": 9})).is_err());
assert!(s.execute("chart.addElement", json!({"element": "trendline", "type": "cubic"})).is_err());
reopen(&mut s);
let after = chart(&s);
assert_eq!(after.series[1].trendlines, c.series[1].trendlines);
assert_eq!(after.series[3].trendlines, c.series[3].trendlines);
s.execute("chart.addElement", json!({"element": "trendline", "on": false})).unwrap();
assert!(chart(&s).series.iter().all(|se| se.trendlines.is_empty()));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(chart(&s).series[1].trendlines.len(), 2);
}
#[test]
fn trendlines_are_refused_on_charts_that_cannot_show_them() {
let mut s = s();
chart_data(&mut s);
s.execute("insert.chart", json!({"type": "pie", "range": "A1:B7"})).unwrap();
assert!(s.execute("chart.addElement", json!({"element": "trendline"})).is_err());
assert!(s.doc().unwrap().wb.active().unwrap().charts[0].series[0].trendlines.is_empty());
}
#[test]
fn switch_row_column_with_whole_column_refs_is_bounded() {
let mut s = s();
@@ -2360,3 +2724,74 @@ fn editing_a_date_cell_shows_a_date_not_the_serial() {
let d = s2.doc().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42");
}
/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `password`.
fn encrypted_book(password: &str) -> Vec<u8> {
let mut src = s();
src.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
let xlsx = crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap();
gridcraft_xlsx::testing::encrypt_workbook(&xlsx, password)
}
#[test]
fn encrypted_workbooks_open_with_their_password() {
const PW: &str = "synthetic-pw-5521";
let b64 = crate::io::base64_encode(&encrypted_book(PW));
let open = |s: &mut Session, password: Option<&str>| {
let mut p = json!({"name": "Secret.xlsx", "base64": b64});
if let Some(pw) = password {
p["password"] = json!(pw);
}
s.execute("file.open", p)
};
let mut s = Session::new();
// Programmatic open without a password: an error carrying the marker, never a dialog.
let err = open(&mut s, None).unwrap_err().to_string();
assert!(err.contains(crate::io::PASSWORD_REQUIRED), "{err}");
assert!(err.contains("Secret.xlsx"), "{err}");
assert!(s.take_ui_requests().is_empty(), "programmatic calls never open dialogs");
assert!(s.documents().is_empty());
// Wrong and empty passwords are "incorrect", and the password is never quoted.
for wrong in ["not-it", ""] {
let err = open(&mut s, Some(wrong)).unwrap_err().to_string();
assert!(err.contains(crate::io::WRONG_PASSWORD), "{err}");
assert!(wrong.is_empty() || !err.contains(wrong), "{err}");
}
assert!(s.documents().is_empty());
// The right password opens it: no save path, the file name as title, the notice first.
let r = open(&mut s, Some(PW)).unwrap();
assert_eq!(r["warnings"][0], crate::io::DECRYPTED_NOTICE);
assert_eq!(r["title"], "Secret.xlsx");
assert_eq!(v(&s, "A1"), Value::Number(42.0));
assert_eq!(s.doc().unwrap().path, None);
// The notice belongs in the info bar, not a message box.
assert!(!s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_))));
let journal = serde_json::to_string(&s.journal).unwrap();
assert!(!journal.contains(PW), "the password never reaches the journal");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
const PW: &str = "synthetic-pw-5522";
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("Secret.xlsx");
let bytes = encrypted_book(PW);
std::fs::write(&path, &bytes).unwrap();
let mut s = s();
s.execute("file.open", json!({"path": path, "password": PW})).unwrap();
assert_eq!(s.doc().unwrap().path, None);
s.take_ui_requests();
s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap();
s.execute("file.save", json!({})).unwrap();
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs")));
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -0,0 +1,102 @@
use gridcraft_core::CellRef;
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Quarterly Revenue"], ["1"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:A2", "header":true})).unwrap();
s
}
fn fit(s: &mut Session) -> f32 {
s.execute("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
s.doc().unwrap().wb.active().unwrap().col_width(0)
}
#[test]
fn autofit_reserves_room_for_table_filter_button() {
let mut s = table();
s.execute("selection.set", json!({"cell":"A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
let plain = fit(&mut s);
s.execute("data.filter", json!({})).unwrap();
let filtered = fit(&mut s);
assert!(filtered >= plain + 16.0, "filter button obscures heading: plain={plain}, filtered={filtered}");
assert_eq!(s.doc().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)).display(), "Quarterly Revenue");
}
fn sheet_mut(s: &mut Session) -> &mut gridcraft_engine::model::Sheet {
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().wb).sheet_mut(0).unwrap()
}
#[test]
fn autofit_skips_disabled_buttons_and_hidden_table_headers() {
let mut s = table();
sheet_mut(&mut s).tables[0].filter_button = false;
let plain = fit(&mut s);
sheet_mut(&mut s).tables[0].filter_button = true;
sheet_mut(&mut s).tables[0].header_row = false;
assert_eq!(fit(&mut s), plain);
sheet_mut(&mut s).tables[0].header_row = true;
assert!(fit(&mut s) > plain);
}
#[test]
fn autofit_matches_excel_filter_allowance_across_zoom_and_row_heights() {
for (zoom, allowance) in [(50, 12.5 * 96.0 / 72.0 * 2.0), (100, 12.5 * 96.0 / 72.0), (200, 12.5 * 96.0 / 72.0)] {
for height in [12.0_f32, 20.0, 60.0] {
let mut s = table();
s.execute("view.zoom", json!({"percent":zoom})).unwrap();
s.execute("home.rowHeight", json!({"rows":"1:1", "height":height})).unwrap();
sheet_mut(&mut s).tables[0].filter_button = false;
let plain = fit(&mut s);
sheet_mut(&mut s).tables[0].filter_button = true;
let filtered = fit(&mut s);
assert!((filtered - plain - allowance).abs() < 1.0, "zoom={zoom}, height={height}, plain={plain}, filtered={filtered}");
}
}
}
#[test]
fn autofit_supports_ordinary_autofilters_without_double_counting_table_buttons() {
let mut s = table();
let table_width = fit(&mut s);
sheet_mut(&mut s).autofilter =
Some(gridcraft_engine::model::AutoFilter { range: gridcraft_engine::core::RangeRef::parse("A1:A2").unwrap(), criteria: vec![] });
assert_eq!(fit(&mut s), table_width, "overlapping filters occupy one button slot");
sheet_mut(&mut s).tables.clear();
assert_eq!(fit(&mut s), table_width, "ordinary filter headers reserve the same space");
sheet_mut(&mut s).autofilter = None;
assert!(fit(&mut s) < table_width);
}
#[test]
fn autofit_leaves_longer_body_cells_and_unfiltered_columns_unchanged() {
let mut s = table();
s.execute("cell.set", json!({"cell":"A2", "input":"A body value much longer than the short table header above"})).unwrap();
let filtered = fit(&mut s);
sheet_mut(&mut s).tables[0].filter_button = false;
assert_eq!(fit(&mut s), filtered);
s.execute("cell.set", json!({"cell":"B1", "input":"Quarterly Revenue"})).unwrap();
s.execute("home.autofitColumnWidth", json!({"cols":"B:B"})).unwrap();
let plain = s.doc().unwrap().wb.active().unwrap().col_width(1);
sheet_mut(&mut s).tables[0].filter_button = true;
s.execute("home.autofitColumnWidth", json!({"cols":"B:B"})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), plain);
}
#[test]
fn autofit_filter_width_is_undoable_without_changing_selection() {
let mut s = table();
s.execute("home.columnWidth", json!({"cols":"A:A", "width":80})).unwrap();
s.execute("selection.set", json!({"range":"A:A"})).unwrap();
let selection = s.doc().unwrap().selection.clone();
s.active_mut().unwrap().undo.clear();
fit(&mut s);
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(s.doc().unwrap().undo.len(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 80.0);
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -91,6 +91,38 @@ pub enum UnOp {
/// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the
/// cell (`ANCHORARRAY` in files).
Spill,
/// Trim reference `A1.:E10` / `A1:.E10` / `A1.:.E10` around a range: the range without its
/// blank leading, trailing or outer rows and columns (`TRIMRANGE` with both trim codes set).
Trim(TrimMode),
}
/// Which blank edges a trim reference drops.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TrimMode {
/// `.:` — leading blank rows and columns.
Leading,
/// `:.` — trailing blank rows and columns.
Trailing,
/// `.:.` — both.
All,
}
impl TrimMode {
pub fn symbol(self) -> &'static str {
match self {
TrimMode::Leading => ".:",
TrimMode::Trailing => ":.",
TrimMode::All => ".:.",
}
}
/// The equivalent `TRIMRANGE` trim_rows / trim_cols code.
pub fn code(self) -> u8 {
match self {
TrimMode::Leading => 1,
TrimMode::Trailing => 2,
TrimMode::All => 3,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+54 -6
View File
@@ -4,6 +4,8 @@
use gridcraft_core::CellError;
use crate::ast::TrimMode;
#[derive(Clone, Debug, PartialEq)]
pub enum Tok {
Number(f64),
@@ -27,6 +29,8 @@ pub enum Tok {
Comma,
Semicolon,
Colon,
/// Trim reference range operator `.:`, `:.` or `.:.`.
Trim(TrimMode),
Space,
Eof,
}
@@ -49,6 +53,20 @@ fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '.' | '$' | '\\' | '?')
}
/// `A1`, `$A$1`, `A`, `$A`, `1`, `$1`: a word that can end a range, so `A1.:` is a trim
/// reference and not a name ending in a dot.
fn is_range_end(w: &str) -> bool {
let s = w.strip_prefix('$').unwrap_or(w);
let (letters, rest) = s.split_at(s.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(s.len()));
let digits = if letters.is_empty() { rest } else { rest.strip_prefix('$').unwrap_or(rest) };
letters.len() <= 3 && if digits.is_empty() { !letters.is_empty() && rest.is_empty() } else { digits.bytes().all(|b| b.is_ascii_digit()) }
}
/// Whether `c` can start the second end of a range after `:.` (`A1:.E10`, `A:.$C`, `1:.5`).
fn starts_range_end(c: Option<char>) -> bool {
c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '$')
}
/// Tokenizes formula text (without the leading `=`).
pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
tokenize_locale(src, crate::FormulaLocale::En)
@@ -178,6 +196,16 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
push(&mut out, Tok::Struct(inner), j);
i = j;
}
'.' if ch(i + 1) == Some(':') => {
// Trim reference `A1.:E10` / `A1.:.E10` (the word before stopped at the dot).
if ch(i + 2) == Some('.') && starts_range_end(ch(i + 3)) {
push(&mut out, Tok::Trim(TrimMode::All), i + 3);
i += 3;
} else {
push(&mut out, Tok::Trim(TrimMode::Leading), i + 2);
i += 2;
}
}
c if c.is_ascii_digit() || (c == locale.decimal_separator() && ch(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
// Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers
// followed by ':' and digits are row refs: lex as Word.
@@ -185,12 +213,22 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
while ch(j).is_some_and(|d| d.is_ascii_digit()) {
j += 1;
}
let is_row_ref = ch(j) == Some(':') && {
let mut k = j + 1;
if ch(k) == Some('$') {
// Row ranges `1:3`, and the trim forms `1.:3`, `1:.3`, `1.:.3`.
let is_row_ref = {
let mut k = j;
if ch(k) == Some('.') && ch(k + 1) == Some(':') {
k += 1;
}
ch(k).is_some_and(|d| d.is_ascii_digit())
ch(k) == Some(':') && {
k += 1;
if ch(k) == Some('.') {
k += 1;
}
if ch(k) == Some('$') {
k += 1;
}
ch(k).is_some_and(|d| d.is_ascii_digit())
}
};
// A word like "1A" can't happen in valid formulas; numbers then.
if is_row_ref {
@@ -252,8 +290,13 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
i += 1;
}
':' => {
push(&mut out, Tok::Colon, i + 1);
i += 1;
if ch(i + 1) == Some('.') && starts_range_end(ch(i + 2)) {
push(&mut out, Tok::Trim(TrimMode::Trailing), i + 2);
i += 2;
} else {
push(&mut out, Tok::Colon, i + 1);
i += 1;
}
}
'+' | '-' | '*' | '/' | '^' | '&' | '%' | '@' | '=' => {
let op = match c {
@@ -315,6 +358,11 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
} else if w.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') {
push(&mut out, Tok::Bool(false), j);
i = j;
} else if w.len() > 1 && w.ends_with('.') && ch(j) == Some(':') && is_range_end(w.get(..w.len() - 1).unwrap_or("")) {
// `A1.:E10`: the dot belongs to the trim operator, lexed next.
let w = w.get(..w.len() - 1).unwrap_or("").to_string();
push(&mut out, Tok::Word(w), j - 1);
i = j - 1;
} else {
push(&mut out, Tok::Word(w), j);
i = j;
+1 -1
View File
@@ -10,7 +10,7 @@ pub mod lexer;
pub mod parser;
pub mod printer;
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
pub use input::{FormulaLocale, parse_input, print_input};
pub use parser::{ParseError, normalize_function_name, parse};
pub use printer::{print, print_r1c1, quote_sheet};
+99 -30
View File
@@ -174,6 +174,17 @@ impl Parser {
lhs = Expr::Invoke(Box::new(lhs), args);
continue;
}
if let Tok::Trim(mode) = *self.peek() {
// A trim reference is the range operator with a modifier: same binding power.
let bp = BinOp::Range.precedence();
if bp < min_bp {
break;
}
self.next();
let rhs = self.expr(bp + 1, allow_union)?;
lhs = Expr::Unary(UnOp::Trim(mode), Box::new(combine(BinOp::Range, lhs, rhs)));
continue;
}
let Some(op) = self.binop(allow_union) else { break };
let bp = op.precedence();
if bp < min_bp {
@@ -243,6 +254,19 @@ impl Parser {
{
return Ok(Expr::Unary(UnOp::At, Box::new(x)));
}
// Files may spell a trim reference as a function call (`_xlfn._TRO_ALL(A1:E10)`).
let trim = match name.as_str() {
"_TRO_LEADING" => Some(TrimMode::Leading),
"_TRO_TRAILING" => Some(TrimMode::Trailing),
"_TRO_ALL" => Some(TrimMode::All),
_ => None,
};
if let Some(mode) = trim
&& args.len() == 1
&& let Some(x) = args.pop()
{
return Ok(Expr::Unary(UnOp::Trim(mode), Box::new(x)));
}
Ok(Expr::Call(name, args))
}
Tok::Sheet(a, b) => {
@@ -260,27 +284,42 @@ impl Parser {
if let Tok::Struct(_) = self.peek()
&& word_is_cell(&w).is_none()
{
let pos = self.pos();
let Tok::Struct(body) = self.next() else { return Err(self.err("internal")) };
return Ok(Expr::Struct(parse_struct(&w, &body)));
return parse_struct(&w, &body).map(Expr::Struct).map_err(|msg| ParseError { msg, pos });
}
self.reference_from_word(SheetSel::Current, &w)
}
Tok::Struct(body) => Ok(Expr::Struct(parse_struct("", &body))),
Tok::Struct(body) => parse_struct("", &body).map(Expr::Struct).map_err(|msg| self.err(msg)),
t => Err(self.err(format!("unexpected {}", describe(&t)))),
}
}
/// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name.
/// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name. A
/// trim operator (`A1:.E10`, `A:.A`) in place of the colon wraps the range in
/// [`UnOp::Trim`].
fn reference_from_word(&mut self, sheet: SheetSel, w: &str) -> Result<Expr, ParseError> {
let trim = match self.peek() {
Tok::Colon => Some(None),
Tok::Trim(mode) => Some(Some(*mode)),
_ => None,
};
let wrap = |kind: RefKind, sheet: SheetSel| {
let r = Expr::Ref(Reference { sheet, kind });
match trim {
Some(Some(mode)) => Expr::Unary(UnOp::Trim(mode), Box::new(r)),
_ => r,
}
};
if let Some(a) = word_is_cell(w) {
// Cell, possibly `A1:B2` (merged here so the sheet applies to both ends).
if *self.peek() == Tok::Colon
if trim.is_some()
&& let Tok::Word(w2) = self.peek_at(1)
&& let Some(b) = word_is_cell(w2)
{
self.next();
self.next();
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) }));
return Ok(wrap(RefKind::Range(a, b), sheet));
}
let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) });
if *self.peek() == Tok::Op("#") {
@@ -294,18 +333,18 @@ impl Parser {
Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")),
_ => None,
};
if *self.peek() == Tok::Colon
if trim.is_some()
&& let Some(w2) = second
{
if let (Some((c0, a0)), Some((c1, a1))) = (word_is_cols(w), word_is_cols(&w2)) {
self.next();
self.next();
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cols(c0, a0, c1, a1) }));
return Ok(wrap(RefKind::Cols(c0, a0, c1, a1), sheet));
}
if let (Some((r0, a0)), Some((r1, a1))) = (word_is_row(w), word_is_row(&w2)) {
self.next();
self.next();
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Rows(r0, a0, r1, a1) }));
return Ok(wrap(RefKind::Rows(r0, a0, r1, a1), sheet));
}
}
if sheet != SheetSel::Current {
@@ -372,8 +411,10 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr {
Expr::Binary(op, Box::new(lhs), Box::new(rhs))
}
/// Parses the inside of `Table[...]`.
pub fn parse_struct(table: &str, body: &str) -> StructRef {
/// Parses the inside of `Table[...]`. Text between the bracketed items may only be the `,` and
/// `:` separators, except one unbracketed column after `[#This Row],` (`[[#This Row],Units]`,
/// how some writers spell `[@Units]`); anything else is an error rather than dropped.
pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
let mut specifiers = Vec::new();
let mut cols: Vec<String> = Vec::new();
let body = body.trim();
@@ -401,24 +442,41 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef {
_ => None,
}
};
let unexpected = |gap: &str| format!("unexpected `{gap}` in structured reference");
// A column name may hold `[` and `]` only when escaped with `'`.
let column = |s: &str| -> Result<String, String> {
let mut esc = false;
for c in s.chars() {
match c {
_ if esc => esc = false,
'\'' => esc = true,
'[' | ']' => return Err(unexpected(s)),
_ => {}
}
}
Ok(unescape(s))
};
if !body.starts_with('[') {
// Simple: [Col], [@Col], [#Headers], [@]
if let Some(rest) = body.strip_prefix('@') {
specifiers.push(StructItem::ThisRow);
let rest = rest.trim().trim_start_matches('[').trim_end_matches(']');
let rest = rest.trim();
let rest = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')).unwrap_or(rest);
if !rest.is_empty() {
cols.push(unescape(rest));
cols.push(column(rest)?);
}
} else if let Some(i) = item(body) {
specifiers.push(i);
} else if !body.is_empty() {
cols.push(unescape(body));
cols.push(column(body)?);
}
} else {
// [[#Headers],[Col1]:[Col3]] or [@[Col 1]]
// [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1]
let mut parts = Vec::new();
let mut depth = 0;
let mut cur = String::new();
// Text between bracketed items.
let mut gap = String::new();
let mut esc = false;
for c in body.chars() {
if esc {
@@ -427,17 +485,26 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef {
continue;
}
match c {
'\'' => {
'\'' if depth > 0 => {
cur.push(c);
esc = true;
}
'[' => {
if depth == 0 {
if !matches!(gap.trim(), "" | "," | ":") {
return Err(unexpected(gap.trim()));
}
gap.clear();
}
depth += 1;
if depth > 1 {
cur.push(c);
}
}
']' => {
if depth == 0 {
return Err(unexpected("]"));
}
depth -= 1;
if depth > 0 {
cur.push(c);
@@ -445,33 +512,35 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef {
parts.push(std::mem::take(&mut cur));
}
}
',' if depth == 0 => {}
':' if depth == 0 => parts.push(":".into()),
_ if depth > 0 => cur.push(c),
_ => {}
_ => gap.push(c),
}
}
let mut range_next = false;
if depth > 0 {
return Err("unclosed `[` in structured reference".to_string());
}
for p in parts {
if p == ":" {
range_next = true;
continue;
}
if let Some(i) = item(&p) {
specifiers.push(i);
} else if range_next {
cols.push(unescape(&p));
range_next = false;
} else {
cols.push(unescape(&p));
cols.push(column(&p)?);
}
}
if body.starts_with("@") {
specifiers.push(StructItem::ThisRow);
let tail = gap.trim();
if !tail.is_empty() {
let bare = tail.strip_prefix(',').map(str::trim).unwrap_or("");
let simple = !bare.is_empty() && !bare.chars().any(|c| matches!(c, '[' | ']' | '#' | '\'' | ',' | ':'));
if !(simple && specifiers == [StructItem::ThisRow] && cols.is_empty()) {
return Err(unexpected(tail));
}
cols.push(bare.to_string());
}
}
if cols.len() > 2 {
return Err("a structured reference names at most two columns".to_string());
}
let mut it = cols.into_iter();
let col_start = it.next();
let col_end = it.next();
StructRef { table: table.to_string(), specifiers, col_start, col_end }
Ok(StructRef { table: table.to_string(), specifiers, col_start, col_end })
}
+36 -6
View File
@@ -39,12 +39,17 @@ fn anchor_a1(a: &Anchor) -> String {
}
pub fn reference_a1(r: &Reference) -> String {
range_a1(r, ":")
}
/// A1 text with `sep` between the two ends of a range (`:` or a trim operator).
fn range_a1(r: &Reference, sep: &str) -> String {
let body = match &r.kind {
RefKind::Cell(a) => anchor_a1(a),
RefKind::Range(a, b) => format!("{}:{}", anchor_a1(a), anchor_a1(b)),
RefKind::Rows(r0, a0, r1, a1) => format!("{}{}:{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1),
RefKind::Range(a, b) => format!("{}{sep}{}", anchor_a1(a), anchor_a1(b)),
RefKind::Rows(r0, a0, r1, a1) => format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1),
RefKind::Cols(c0, a0, c1, a1) => {
format!("{}{}:{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1))
format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1))
}
};
format!("{}{}", sheet_prefix(&r.sheet), body)
@@ -64,16 +69,22 @@ fn anchor_r1c1(a: &Anchor, at: CellRef) -> String {
}
pub fn reference_r1c1(r: &Reference, at: CellRef) -> String {
range_r1c1(r, at, ":")
}
/// R1C1 text with `sep` between the two ends of a range; a trim operator never collapses
/// `R1:R1` to `R1`.
fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String {
let body = match &r.kind {
RefKind::Cell(a) => anchor_r1c1(a, at),
RefKind::Range(a, b) => format!("{}:{}", anchor_r1c1(a, at), anchor_r1c1(b, at)),
RefKind::Range(a, b) => format!("{}{sep}{}", anchor_r1c1(a, at), anchor_r1c1(b, at)),
RefKind::Rows(r0, a0, r1, a1) => {
let (x, y) = (r1c1_part('R', *r0, *a0, at.row), r1c1_part('R', *r1, *a1, at.row));
if x == y { x } else { format!("{x}:{y}") }
if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") }
}
RefKind::Cols(c0, a0, c1, a1) => {
let (x, y) = (r1c1_part('C', *c0, *a0, at.col), r1c1_part('C', *c1, *a1, at.col));
if x == y { x } else { format!("{x}:{y}") }
if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") }
}
};
format!("{}{}", sheet_prefix(&r.sheet), body)
@@ -193,6 +204,25 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
s.push('#');
}
}
UnOp::Trim(mode) => match &**x {
Expr::Ref(r) if !matches!(r.kind, RefKind::Cell(_)) => match r1c1 {
Some(at) => s.push_str(&range_r1c1(r, at, mode.symbol())),
None => s.push_str(&range_a1(r, mode.symbol())),
},
Expr::Binary(BinOp::Range, a, b) => {
write_expr(s, a, r1c1);
s.push_str(mode.symbol());
write_expr(s, b, r1c1);
}
// A reference that became #REF! stays `#REF!`.
Expr::Error(_) => write_expr(s, x, r1c1),
// No operator form (e.g. a range shrunk to one cell): the equivalent call.
other => {
s.push_str("TRIMRANGE(");
write_expr(s, other, r1c1);
let _ = write!(s, ",{0},{0})", mode.code());
}
},
},
Expr::Binary(op, a, b) => {
write_expr(s, a, r1c1);
+81
View File
@@ -220,3 +220,84 @@ fn spans() {
let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).unwrap();
assert_eq!((a1.start, a1.end), (4, 6));
}
#[test]
fn unbracketed_this_row_column() {
// Writers such as xlsxwriter spell `[@Units]` as `[[#This Row],Units]`.
let p = |f: &str| parse(f).unwrap_or_else(|e| panic!("{f}: {e}"));
let Expr::Binary(BinOp::Mul, a, _) = p("[[#This Row],Units]*[[#This Row],Price]") else { panic!() };
let Expr::Struct(s) = *a else { panic!() };
assert_eq!((s.specifiers.as_slice(), s.col_start.as_deref()), ([StructItem::ThisRow].as_slice(), Some("Units")));
assert_eq!(rt("[[#This Row],Units]*[[#This Row],Price]"), "[@Units]*[@Price]");
assert_eq!(rt("Sales[[#This Row],Units]"), "Sales[@Units]");
assert_eq!(rt("Sales[[#This Row], Units ]"), "Sales[@Units]");
assert_eq!(rt("[[#This Row],Units]+[@[Unit Price]]+Sales[[#This Row],[Qty]]"), "[@Units]+[@[Unit Price]]+Sales[@Qty]");
// Unexpected text between the brackets is an error, never silently dropped.
for bad in [
"[[#This Row]x]",
"[[#This Row],[A]]x",
"[[#Data],Col]",
"[[#This Row],A:B]",
"[x[#This Row]]",
"[[#This Row],]x",
"Sales[[#All],[A]]junk",
"Sales[[A],[B],[C]]",
"Sales[@[A]x]",
] {
assert!(parse(bad).is_err(), "{bad} should fail");
}
}
#[test]
fn trim_references() {
for f in [
"SUM(A1:.E10)",
"SUM(A1.:E10)",
"SUM(A1.:.E10)",
"SUM(A:.A)",
"SUM($A.:.$C)",
"ROWS(1:.5)",
"ROWS($1.:$5)",
"ROWS(1.:.5)",
"Sheet2!$A$1:.B9",
"Q1.2026!A1.:.B2",
"'Q1 2026'!A1:.B2",
"SUM(A1:.A9,B1.:B9)",
"A1:.A9*2",
] {
assert_eq!(rt(f), f, "round trip of {f}");
}
let Expr::Call(_, args) = parse("SUM(A1:.E10)").unwrap() else { panic!() };
assert!(matches!(args.as_slice(), [Expr::Unary(UnOp::Trim(TrimMode::Trailing), r)] if matches!(**r, Expr::Ref(_))));
// Decimals, row ranges and dotted names keep their meaning.
assert_eq!(parse("1.5+.5").unwrap(), Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5))));
for f in ["SUM(1:5)", "Q1.2026!A1:B2", "my.name+1", "SUM(A1:B2)*1.5", "1.:5"] {
assert_eq!(rt(f), f);
}
assert_eq!(rt("'Q1.2026'!A1:B2"), "Q1.2026!A1:B2");
assert!(matches!(parse("Q1.2026!A1:B2").unwrap(), Expr::Ref(_)));
// File call forms.
assert_eq!(rt("SUM(_xlfn._TRO_TRAILING(A1:E10))"), "SUM(A1:.E10)");
assert_eq!(rt("_xlfn._TRO_LEADING(A:A)"), "A.:A");
assert_eq!(rt("_xlfn._TRO_ALL(Sheet1!A1:B2)"), "Sheet1!A1.:.B2");
// Not a range: printed as the equivalent call.
assert_eq!(rt("_xlfn._TRO_ALL(B2)"), "TRIMRANGE(B2,3,3)");
for bad in ["A1.:", ".:A1", "A1:.:B2", "A1.:.:B2"] {
assert!(parse(bad).is_err(), "{bad} should fail");
}
// R1C1 keeps the operator.
assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)").unwrap(), gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])");
}
#[test]
fn trim_references_adjust() {
let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 };
let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)").unwrap();
assert_eq!(print(&adjust(e, "S", "S", &ins)), "SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)");
let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 };
let e = parse("SUM(A1.:C1)+SUM(A:.A)").unwrap();
assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1.:B1)+SUM(#REF!)");
assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)").unwrap(), 1, 1)), "SUM(B2.:.C3)");
let refs: Vec<String> = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect();
assert_eq!(refs, ["Sheet2!A1:B5"]);
}
+10 -1
View File
@@ -496,7 +496,16 @@ pub(crate) fn specs() -> Vec<FnSpec> {
f!("ISOWEEKNUM", 1, 1, DateTime, S, "ISOWEEKNUM(date)", "Returns the ISO 8601 week number of a date.", isoweeknum),
f!("EDATE", 2, 2, DateTime, S, "EDATE(start_date, months)", "Returns the date a number of months before or after a date.", edate),
f!("EOMONTH", 2, 2, DateTime, S, "EOMONTH(start_date, months)", "Returns the last day of the month a number of months away.", eomonth),
f!("DATEDIF", 3, 3, DateTime, S, "DATEDIF(start_date, end_date, unit)", "Counts whole years, months or days between two dates.", datedif),
f!(
"DATEDIF",
3,
3,
DateTime,
S,
"DATEDIF(start_date, end_date, unit)",
"Counts whole years, months or days between two dates. unit: Y, M, D, MD, YM or YD.",
datedif
),
f!("DATEVALUE", 1, 1, DateTime, S, "DATEVALUE(date_text)", "Converts a date written as text into a date serial number.", datevalue),
f!("TIMEVALUE", 1, 1, DateTime, S, "TIMEVALUE(time_text)", "Converts a time written as text into a fraction of a day.", timevalue),
f!("DAYS", 2, 2, DateTime, S, "DAYS(end_date, start_date)", "Returns the number of days between two dates.", days),
+6 -1
View File
@@ -160,6 +160,11 @@ impl FnSpec {
None => self.scalar_args.last().copied().unwrap_or(false),
}
}
/// Whether a call with `n` arguments is valid (Excel allows at most 255).
pub fn accepts(&self, n: usize) -> bool {
(self.min_args..=self.max_args).contains(&n) && n <= 255
}
}
/// Variadic marker for `max_args`.
@@ -215,7 +220,7 @@ pub fn all() -> &'static [FnSpec] {
/// Calls a function with array lifting applied. Wrong arity gives `#VALUE!`.
pub fn call(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
if args.len() < spec.min_args || args.len() > spec.max_args || args.len() > 255 {
if !spec.accepts(args.len()) {
return Value::Error(CellError::Value);
}
lift::call_lifted(spec, args, ctx)
+141 -32
View File
@@ -33,43 +33,77 @@ fn find_exact(x: &Value, hay: &[Value], wild: bool, reverse: bool) -> Option<usi
if reverse { hay.iter().rposition(|v| lookup_equal(x, v, wild)) } else { hay.iter().position(|v| lookup_equal(x, v, wild)) }
}
/// Binary search over the entries of the needle's type, assuming they are sorted ascending
/// (`desc = false`) or descending. Returns the position of the last entry that is `<= x`
/// (ascending) or `>= x` (descending), like Excel's approximate match.
/// Approximate match of VLOOKUP, HLOOKUP, LOOKUP and MATCH: the position of the largest entry
/// `<= x` in data sorted ascending (`desc = false`), or of the smallest entry `>= x` in data
/// sorted descending. Like Excel this is a bisection over the whole vector, so on unsorted data
/// it answers what Excel answers rather than a linear "best" entry:
/// - the probe is the middle entry of the remaining window; a probe whose type differs from the
/// needle (text vs number vs logical, blanks, errors) is replaced by the nearest entry of the
/// needle's type below it in the window; when there is none the window moves up past it, so
/// entries of other types are skipped (`LOOKUP(9.9E+307, range)` finds the last number);
/// - a probe equal to the needle ends the search at once (the run of equal entries that follows
/// it is taken, so sorted duplicates give the last one).
///
/// Observed in Excel: `VLOOKUP(534, {"INTEGRAL";0;1;2;534;9999999999;-9999999999}, …)` finds 534,
/// `HLOOKUP("STRING", {"INTEGRAL","DOUBLE","BLANK","STRING","REF","AREA (rows)",<blank>}, …)`
/// finds "STRING" and `LOOKUP("DOUBLE", {"DOUBLE","BLANK","STRING","REF","AREA (rows)"}, …)`
/// finds "DOUBLE". Work is linear in the vector length at worst.
fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
let idx: Vec<usize> = hay.iter().enumerate().filter(|(_, v)| same_type(x, v)).map(|(i, _)| i).collect();
let (mut lo, mut hi) = (0usize, idx.len());
// Find the first filtered position where the "passes" predicate fails.
while lo < hi {
let mut hi = hay.len().checked_sub(1)?;
let mut lo = 0usize;
let mut best = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let v = idx.get(mid).and_then(|&i| hay.get(i))?;
let o = compare(v, x);
let ok = if desc { o != Ordering::Less } else { o != Ordering::Greater };
if ok {
let Some(j) = (lo..=mid).rev().find(|&j| hay.get(j).is_some_and(|v| same_type(x, v))) else {
lo = mid + 1;
} else {
hi = mid;
}
}
if lo == 0 { None } else { idx.get(lo - 1).copied() }
}
/// Binary search returning the first entry `>= x` (ascending) or `<= x` (descending).
fn bsearch_first(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
let idx: Vec<usize> = hay.iter().enumerate().filter(|(_, v)| same_type(x, v)).map(|(i, _)| i).collect();
let (mut lo, mut hi) = (0usize, idx.len());
while lo < hi {
let mid = lo + (hi - lo) / 2;
let v = idx.get(mid).and_then(|&i| hay.get(i))?;
continue;
};
let v = hay.get(j)?;
let o = compare(v, x);
if o == Ordering::Equal {
let mut k = j;
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
k += 1;
}
return Some(k);
}
let before = if desc { o == Ordering::Greater } else { o == Ordering::Less };
if before {
best = Some(j);
lo = mid + 1;
} else if j == 0 {
break;
} else {
hi = mid;
hi = j - 1;
}
}
idx.get(lo).copied()
best
}
/// Order of an entry relative to the needle for XLOOKUP / XMATCH binary search (search mode ±2):
/// Excel's sort order over all types (numbers < text < logicals < errors), reversed for
/// descending data, with blanks always last like a sort leaves them. Unlike the legacy
/// approximate match no entry is skipped (observed: `XLOOKUP(46523, {9700;"xyz";"xyz";160726;
/// 204100;510300}, …, 0, 1, 2)` returns the row of the first "xyz").
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
match v {
Value::Empty => Ordering::Greater,
_ if desc => compare(v, x).reverse(),
_ => compare(v, x),
}
}
/// Binary search for the first entry not ordered before the needle (`>= x` ascending, `<= x`
/// descending).
fn bsearch_first(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
let p = hay.partition_point(|v| binary_order(v, x, desc) == Ordering::Less);
(p < hay.len()).then_some(p)
}
/// Binary search for the last entry not ordered after the needle (`<= x` ascending, `>= x`
/// descending).
fn bsearch_upto(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
hay.partition_point(|v| binary_order(v, x, desc) != Ordering::Greater).checked_sub(1)
}
/// A 1-D view of an array (row or column), `None` when it is 2-D.
@@ -199,12 +233,13 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
if desc {
bsearch_first(x, hay, true)
} else {
bsearch_last(x, hay, false)
bsearch_upto(x, hay, false)
}
}
// Next larger: first >= x (ascending) / last >= x (descending).
_ => {
if desc {
bsearch_last(x, hay, true)
bsearch_upto(x, hay, true)
} else {
bsearch_first(x, hay, false)
}
@@ -248,7 +283,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
fn bsearch_exact(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
let p = bsearch_first(x, hay, desc)?;
let v = hay.get(p)?;
if compare(v, x) == Ordering::Equal { Some(p) } else { None }
if binary_order(v, x, desc) == Ordering::Equal { Some(p) } else { None }
}
fn xlookup(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -378,9 +413,16 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
}
/// Sheet text for ADDRESS, quoted when it needs to be. A `[Book]Sheet` prefix is judged by its
/// parts (Microsoft's ADDRESS examples: `"[Book1]Sheet1"` stays bare, `"EXCEL SHEET"` becomes
/// `'EXCEL SHEET'`).
fn quote_sheet(s: &str) -> String {
let plain =
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') && !s.chars().next().is_some_and(|c| c.is_ascii_digit());
let word = |p: &str| p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.');
let bare_sheet = |p: &str| !p.is_empty() && word(p) && !p.chars().next().is_some_and(|c| c.is_ascii_digit());
let plain = match s.strip_prefix('[').and_then(|r| r.split_once(']')) {
Some((book, sheet)) => !book.is_empty() && word(book) && bare_sheet(sheet),
None => bare_sheet(s),
};
if plain { s.to_string() } else { format!("'{}'", s.replace('\'', "''")) }
}
@@ -574,6 +616,45 @@ mod tests {
close(ev("LOOKUP", vec![t("zz"), av(mixed), av(row(&[1.0, 2.0, 3.0, 4.0]))]), 4.0);
}
/// Approximate matches over unsorted, mixed-type data as observed in Excel
/// (FormulaEvalTestData rows "Vlookup", "Hlookup", "Lookup"): Excel bisects the whole
/// vector and stops at an equal entry, so answers differ from a "best entry" scan.
#[test]
fn approximate_match_follows_bisection() {
// B6:H12 of the test data: header row, then one row per data kind.
let data = arr(vec![
vec![tv("INTEGRAL"), tv("DOUBLE"), tv("BLANK"), tv("STRING"), tv("REF"), tv("AREA (rows)"), Value::Empty],
vec![nv(0.0), nv(0.0), Value::Empty, tv(""), nv(0.0), nv(1.0), nv(2.0)],
vec![nv(1.0), nv(0.00001), Value::Empty, tv("nospaces"), nv(1.0), nv(1.001), nv(2.999)],
vec![nv(2.0), nv(1.1), Value::Empty, tv("sp ace s"), nv(9999999999.0), nv(2.3456), nv(3.4567)],
vec![nv(534.0), nv(1.00001), Value::Empty, tv("endsWithSpace "), nv(0.00001), tv("alphanum"), nv(3.0)],
vec![nv(9999999999.0), nv(2.99999), Value::Empty, tv(" beginsWithSpace"), nv(0.0), tv("blanksIncl"), Value::Empty],
vec![nv(-9999999999.0), nv(999999.99999), Value::Empty, tv("non-numeric"), tv("sp ace s"), tv("E8"), nv(545.0)],
]);
close(ev("VLOOKUP", vec![n(534.0), rf(data.clone()), n(2.0)]), 1.00001);
is_text(ev("VLOOKUP", vec![n(534.0), rf(data.clone()), n(4.0)]), "endsWithSpace ");
is_text(ev("VLOOKUP", vec![n(534.0), rf(data.clone()), n(4.0), b(true)]), "endsWithSpace ");
is_text(ev("HLOOKUP", vec![t("STRING"), rf(data.clone()), n(6.0), b(true)]), " beginsWithSpace");
let keys = arr(vec![vec![tv("DOUBLE"), tv("BLANK"), tv("STRING"), tv("REF"), tv("AREA (rows)")]]);
let results = arr(vec![vec![nv(1.1), Value::Empty, tv("sp ace s"), nv(9999999999.0), nv(2.3456)]]);
close(ev("LOOKUP", vec![t("DOUBLE"), rf(keys), rf(results)]), 1.1);
let b6_b15 = col(&[0.0, 1.0, 2.0, 534.0, 9999999999.0, -9999999999.0, -534.0, -2.0, -1.0]);
let mut keys = vec![tv("INTEGRAL")];
keys.extend(rows_of(&b6_b15).into_iter().flatten());
let words = ["STRING", "", "nospaces", "sp ace s", "endsWithSpace ", " beginsWithSpace", "non-numeric", "0", "0.000001", "1"];
let e_col = arr(words.iter().map(|w| vec![tv(w)]).collect());
is_text(ev("LOOKUP", vec![n(534.0), rf(arr(keys.into_iter().map(|k| vec![k]).collect())), rf(e_col)]), "endsWithSpace ");
// Sorted duplicates give the last one; descending data with MATCH -1 too.
close(ev("MATCH", vec![n(2.0), av(row(&[1.0, 2.0, 2.0, 2.0, 3.0])), n(1.0)]), 4.0);
close(ev("MATCH", vec![n(20.0), av(row(&[40.0, 20.0, 20.0, 10.0])), n(-1.0)]), 3.0);
// Only text and blanks: no number to find.
is_err(ev("MATCH", vec![n(5.0), av(arr(vec![vec![tv("a"), Value::Empty, tv("b")]]))]), CellError::NA);
// A long run of trailing blanks (whole-column lookups) still finds the last number.
let mut tall = vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(3.0)]];
tall.extend((0..10_000).map(|_| vec![Value::Empty]));
close(ev("MATCH", vec![n(9.0), rf(arr(tall))]), 3.0);
}
#[test]
fn match_fn() {
let v = col(&[10.0, 20.0, 30.0, 40.0]);
@@ -632,6 +713,23 @@ mod tests {
is_err(ev("XLOOKUP", vec![n(2.0), rf(table()), rf(vals)]), CellError::Value);
}
/// XLOOKUP binary search (search mode ±2). Observed in Excel (xlookup.xlsx, Sheet2): next
/// larger over ascending data with text mixed in (0.22) and over descending data (0.24); the
/// bisection compares across types in sort order (numbers < text) instead of skipping text.
/// The other modes follow from the same order.
#[test]
fn xlookup_binary_search_observed() {
let rates = col(&[0.1, 0.22, 0.24, 0.32, 0.35, 0.37]);
let incomes = arr(vec![vec![nv(9700.0)], vec![tv("xyz")], vec![tv("xyz")], vec![nv(160726.0)], vec![nv(204100.0)], vec![nv(510300.0)]]);
close(ev("XLOOKUP", vec![n(46523.0), rf(incomes.clone()), rf(rates.clone()), n(0.0), n(1.0), n(2.0)]), 0.22);
close(ev("XLOOKUP", vec![n(46523.0), rf(incomes), rf(rates), n(0.0), n(-1.0), n(2.0)]), 0.1);
let desc_incomes = col(&[510300.0, 204100.0, 160726.0, 84200.0, 39475.0, 9700.0]);
let desc_rates = col(&[0.37, 0.35, 0.32, 0.24, 0.22, 0.1]);
close(ev("XLOOKUP", vec![n(46523.0), rf(desc_incomes.clone()), rf(desc_rates.clone()), n(0.0), n(1.0), n(-2.0)]), 0.24);
close(ev("XLOOKUP", vec![n(46523.0), rf(desc_incomes.clone()), rf(desc_rates.clone()), n(0.0), n(-1.0), n(-2.0)]), 0.22);
close(ev("XLOOKUP", vec![n(84200.0), rf(desc_incomes), rf(desc_rates), n(0.0), n(0.0), n(-2.0)]), 0.24);
}
fn table_rows3() -> Value {
arr(vec![vec![nv(1.0), tv("one"), nv(10.0)], vec![nv(2.0), tv("two"), nv(20.0)], vec![nv(3.0), tv("three"), nv(30.0)]])
}
@@ -682,6 +780,17 @@ mod tests {
is_text(ev("ADDRESS", vec![n(2.0), n(3.0), n(4.0), b(false)]), "R[2]C[3]");
is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t("Sheet1")]), "Sheet1!$A$1");
is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t("My Sheet")]), "'My Sheet'!$A$1");
// Observed in Excel (FormulaEvalTestData) and Microsoft's ADDRESS examples; the last
// case follows from the quoting rule for sheet names.
let cases: [(f64, f64, f64, bool, &str, &str); 4] = [
(2.0, 3.0, 3.0, true, "[Book1]Sheet1", "[Book1]Sheet1!$C2"),
(2.0, 3.0, 1.0, false, "[Book1]Sheet1", "[Book1]Sheet1!R2C3"),
(2.0, 3.0, 1.0, false, "EXCEL SHEET", "'EXCEL SHEET'!R2C3"),
(1.0, 1.0, 1.0, true, "[My Book]Sheet1", "'[My Book]Sheet1'!$A$1"),
];
for (r, c, abs, a1, sheet, want) in cases {
is_text(ev("ADDRESS", vec![n(r), n(c), n(abs), b(a1), t(sheet)]), want);
}
is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576");
is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value);
is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value);
+29 -4
View File
@@ -109,20 +109,24 @@ fn product(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(v.iter().product())
}
/// SUMPRODUCT: non-numeric entries of arrays and multi-cell ranges count as 0, but a scalar
/// argument holding text (a literal or a single-cell reference) is `#VALUE!` (observed:
/// `SUMPRODUCT(G1330:G1330,I1330:I1330)` with G1330 = the text "4" is `#VALUE!`).
fn sumproduct(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let arrays: Vec<_> = a.iter().map(|x| as_array(&x.value)).collect();
let Some(first) = arrays.first() else { return Err(CellError::Value) };
let arrays: Vec<_> = a.iter().map(|x| (as_array(&x.value), !matches!(x.value, Value::Array(_)))).collect();
let Some((first, _)) = arrays.first() else { return Err(CellError::Value) };
let (rows, cols) = (first.rows, first.cols);
if arrays.iter().any(|x| x.rows != rows || x.cols != cols) {
if arrays.iter().any(|(x, _)| x.rows != rows || x.cols != cols) {
return Err(CellError::Value);
}
let mut total = 0.0;
for i in 0..rows.saturating_mul(cols) {
let mut p = 1.0;
for arr in &arrays {
for (arr, scalar) in &arrays {
match arr.data.get(i) {
Some(Value::Number(n)) => p *= n,
Some(Value::Error(e)) => return Err(*e),
Some(Value::Text(_)) if *scalar => return Err(CellError::Value),
_ => p = 0.0,
}
}
@@ -1336,6 +1340,27 @@ mod tests {
is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA);
}
/// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and
/// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match.
#[test]
fn sumproduct_scalar_arguments() {
let cases: Vec<(Vec<crate::Arg>, Result<f64, CellError>)> = vec![
(vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)),
(vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)),
(vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)),
(vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)),
(vec![t("4"), n(2.0)], Err(CellError::Value)),
(vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)),
(vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)),
];
for (args, want) in cases {
match want {
Ok(x) => close(ev("SUMPRODUCT", args), x),
Err(err) => is_err(ev("SUMPRODUCT", args), err),
}
}
}
#[test]
fn rounding() {
close(ev2("ROUND", 2.675, 2.0), 2.68);
+60 -3
View File
@@ -78,6 +78,34 @@ fn mode_sngl(v: &[f64]) -> R<f64> {
modes(v).first().copied().ok_or(CellError::NA)
}
/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers
/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!`
/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or
/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`).
/// Arguments are processed left to right, so the first error wins.
fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
let mut out = Vec::new();
for a in args {
match &a.value {
Value::Array(arr) => {
for v in arr.iter() {
match v {
Value::Number(n) => out.push(*n),
Value::Error(e) => return Err(*e),
_ => {}
}
}
}
Value::Number(n) => out.push(*n),
Value::Error(e) => return Err(*e),
Value::Text(_) | Value::Empty => return Err(CellError::Value),
Value::Bool(_) if a.from_ref => {}
v => out.push(v.to_number()?),
}
}
Ok(out)
}
fn product(v: &[f64]) -> f64 {
if v.is_empty() { 0.0 } else { v.iter().product() }
}
@@ -277,7 +305,7 @@ fn countblank(a: &[Arg], _: C) -> R<Value> {
}
fn mode_mult(a: &[Arg], _: C) -> R<Value> {
let v = numbers(a)?;
let v = mode_numbers(a)?;
let m = modes(&v);
if m.is_empty() {
return Err(CellError::NA);
@@ -1566,10 +1594,10 @@ pub(crate) fn specs() -> Vec<FnSpec> {
numbers(a)?
)?)),
f!("MODE", 1, VAR, Compatibility, A, "MODE(number1, [number2], ...)", "Most frequently occurring number.", |a: &[Arg], _c: C| num_val(
mode_sngl(&numbers(a)?)?
mode_sngl(&mode_numbers(a)?)?
)),
f!("MODE.SNGL", 1, VAR, Statistical, A, "MODE.SNGL(number1, [number2], ...)", "Most frequently occurring number.", |a: &[Arg], _c: C| {
num_val(mode_sngl(&numbers(a)?)?)
num_val(mode_sngl(&mode_numbers(a)?)?)
}),
f!("MODE.MULT", 1, VAR, Statistical, A, "MODE.MULT(number1, [number2], ...)", "Vertical array of all the most frequent numbers.", mode_mult),
f!("STDEV", 1, VAR, Compatibility, A, "STDEV(number1, [number2], ...)", "Sample standard deviation.", |a: &[Arg], _c: C| num_val(
@@ -2251,6 +2279,35 @@ mod tests {
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]);
}
/// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell
/// ranges skip text and blanks, but direct text and single-cell references to text or blank
/// cells are `#VALUE!`; the first error from the left wins.
#[test]
fn mode_scalar_arguments() {
let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]);
let cases: Vec<(Vec<crate::Arg>, Result<f64, CellError>)> = vec![
(vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)),
(vec![rf(data)], Ok(1.0)),
(vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)),
// MODE(C9,E16,"1",B8): E16 holds the text "1.1".
(vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)),
(vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)),
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)),
// MODE(B9,2,D7) with D7 blank.
(vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)),
(vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)),
];
for name in ["MODE", "MODE.SNGL"] {
for (args, want) in &cases {
match want {
Ok(x) => close(ev(name, args.clone()), *x),
Err(err) => is_err(ev(name, args.clone()), *err),
}
}
}
is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value);
}
#[test]
fn spread() {
let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0];
+22 -6
View File
@@ -292,17 +292,14 @@ fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(ch as u32 as f64)
}
/// VALUE: a blank cell reads as 0, but empty or blank text is not a number (observed:
/// `VALUE("")` is `#VALUE!`).
fn value(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
match scalar(arg(a, 0)?) {
Value::Number(n) => num_val(n),
Value::Empty => num_val(0.0),
Value::Error(e) => Err(e),
Value::Text(t) => {
if t.trim().is_empty() {
return num_val(0.0);
}
parse_number_text(&t).ok_or(CellError::Value).and_then(num_val)
}
Value::Text(t) => parse_number_text(&t).ok_or(CellError::Value).and_then(num_val),
_ => Err(CellError::Value),
}
}
@@ -1054,6 +1051,25 @@ mod tests {
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
}
/// VALUE cases observed in Excel (ValueFunctionOfBlank, FormulaEvalTestData); a blank cell
/// still reads as 0.
#[test]
fn value_observed() {
let cases: Vec<(crate::Arg, Result<f64, CellError>)> = vec![
(t(""), Err(CellError::Value)),
(t("ABC"), Err(CellError::Value)),
(t("0.33"), Ok(0.33)),
(t("$1,000"), Ok(1000.0)),
(rf(Value::Empty), Ok(0.0)),
];
for (arg, want) in cases {
match want {
Ok(x) => close(ev("VALUE", vec![arg]), x),
Err(err) => is_err(ev("VALUE", vec![arg]), err),
}
}
}
#[test]
fn numbers_from_text() {
close(ev("VALUE", vec![t("1,234.5")]), 1234.5);
+70
View File
@@ -65,6 +65,11 @@ pub struct Table {
pub first_col: bool,
pub last_col: bool,
pub filter_button: bool,
/// Criteria of the table's AutoFilter (column offset within the table → criterion). With
/// criteria present, hidden data rows are filtered out rather than hidden by hand, which
/// SUBTOTAL distinguishes.
#[serde(default)]
pub filter: Vec<(u32, FilterCriterion)>,
}
impl Table {
@@ -388,6 +393,65 @@ pub struct Series {
/// Draw line segments as a smoothed (spline) curve.
#[serde(default)]
pub smooth: bool,
/// Trendlines fitted to this series (Excel allows several per series).
#[serde(default)]
pub trendlines: Vec<Trendline>,
}
/// Regression a trendline fits (`c:trendlineType`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrendlineKind {
#[default]
Linear,
/// `y = c·e^(b·x)`.
Exponential,
/// `y = c·ln(x) + b`.
Logarithmic,
/// Polynomial of this order (2..=6).
Polynomial(u8),
/// `y = c·x^b`.
Power,
/// Average of this many consecutive points (2..=255).
MovingAverage(u32),
}
/// DrawingML preset line dash (`a:prstDash`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LineDash {
#[default]
Solid,
Dot,
Dash,
LgDash,
DashDot,
LgDashDot,
LgDashDotDot,
SysDash,
SysDot,
SysDashDot,
SysDashDotDot,
}
/// A chart trendline (Add Chart Element ▸ Trendline, Format Trendline pane).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Trendline {
pub kind: TrendlineKind,
/// Custom name shown in the legend; `None` = automatic ("Linear (Sales)").
pub name: Option<String>,
/// Forecast past the last point, in x units (categories on a category axis).
pub forward: f64,
/// Forecast before the first point, in x units.
pub backward: f64,
/// Set Intercept: fixed y at x = 0 (linear, exponential, polynomial).
pub intercept: Option<f64>,
pub display_equation: bool,
pub display_r_squared: bool,
/// Line colour; `None` = the series colour.
pub color: Option<Color>,
/// Line width in points; `None` = default.
pub width: Option<f32>,
/// Line dash; `None` = default.
pub dash: Option<LineDash>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -534,6 +598,12 @@ pub enum FilterCriterion {
AboveAverage(bool),
FillColor(Color),
FontColor(Color),
/// Rows whose cell shows icon `id` (0-based; `None` = no icon) of conditional-format icon set
/// `set` (`iconFilter`). Kept from files; re-applying the filter leaves those rows as they are.
Icon {
set: String,
id: Option<u32>,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+25
View File
@@ -221,6 +221,20 @@ impl Sheet {
// ---- geometry
/// Whether this cell has a table or ordinary AutoFilter dropdown.
pub fn has_filter_button(&self, c: CellRef) -> bool {
self.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && af.range.contains(c))
|| self.tables.iter().any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && t.range.contains(c))
}
/// Horizontal header space in unzoomed grid units (96 units per inch).
/// Observed in Windows Excel 16.0 build 20326: AutoFit reserves 12.5 pt at
/// 100-200% zoom, 25 pt at 50%, regardless of header row height. Below 100%
/// the dropdown keeps its screen width. This is geometry, not font padding.
pub fn filter_button_space(&self, c: CellRef) -> f32 {
if self.has_filter_button(c) { (12.5 * 96.0 / 72.0) / (self.zoom as f32 / 100.0).clamp(0.1, 1.0) } else { 0.0 }
}
pub fn col_width(&self, col: u32) -> f32 {
match self.cols.get(&col) {
Some(i) if i.hidden => 0.0,
@@ -238,6 +252,17 @@ impl Sheet {
pub fn is_row_hidden(&self, row: u32) -> bool {
self.rows.get(&row).is_some_and(|r| r.hidden)
}
/// Whether `row` is hidden by an applied filter: a hidden row below the header of the
/// sheet AutoFilter while it has criteria, or a hidden data row of a filtered table.
/// Other hidden rows were hidden by hand.
pub fn is_row_filtered(&self, row: u32) -> bool {
if !self.is_row_hidden(row) {
return false;
}
let in_sheet_filter =
self.autofilter.as_ref().is_some_and(|af| !af.criteria.is_empty() && row > af.range.start.row && row <= af.range.end.row);
in_sheet_filter || self.tables.iter().any(|t| !t.filter.is_empty() && t.data_range().is_some_and(|r| row >= r.start.row && row <= r.end.row))
}
pub fn is_col_hidden(&self, col: u32) -> bool {
self.cols.get(&col).is_some_and(|r| r.hidden)
}
+36 -1
View File
@@ -18,11 +18,16 @@ pub enum CalcMode {
Manual,
}
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CalcSettings {
pub mode: CalcMode,
/// Enable iterative calculation: circular references are calculated repeatedly instead of
/// reporting an error.
pub iterative: bool,
/// Maximum Iterations, 1 to [`CalcSettings::MAX_ITERATIONS`].
pub max_iterations: u32,
/// Maximum Change: iteration stops once no value in a circular reference changes by more.
pub max_change: f64,
/// Use precision as displayed.
pub precision_as_displayed: bool,
@@ -30,7 +35,32 @@ pub struct CalcSettings {
impl Default for CalcSettings {
fn default() -> Self {
CalcSettings { mode: CalcMode::Automatic, iterative: false, max_iterations: 100, max_change: 0.001, precision_as_displayed: false }
CalcSettings {
mode: CalcMode::Automatic,
iterative: false,
max_iterations: Self::DEFAULT_ITERATIONS,
max_change: Self::DEFAULT_CHANGE,
precision_as_displayed: false,
}
}
}
impl CalcSettings {
pub const DEFAULT_ITERATIONS: u32 = 100;
pub const DEFAULT_CHANGE: f64 = 0.001;
/// The largest Maximum Iterations Excel accepts.
pub const MAX_ITERATIONS: u32 = 32_767;
/// Sets Maximum Iterations, kept within 1 to [`CalcSettings::MAX_ITERATIONS`].
pub fn set_max_iterations(&mut self, n: u32) {
self.max_iterations = n.clamp(1, Self::MAX_ITERATIONS);
}
/// Sets Maximum Change (its magnitude); a value that isn't finite is ignored.
pub fn set_max_change(&mut self, d: f64) {
if d.is_finite() {
self.max_change = d.abs();
}
}
}
@@ -79,6 +109,10 @@ pub struct Workbook {
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros.
#[serde(default, with = "crate::b64::opt")]
pub vba_project: Option<Vec<u8>>,
/// Field separator of the CSV this workbook was read from, so saving it as CSV writes the same
/// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub csv_delimiter: Option<u8>,
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
@@ -107,6 +141,7 @@ impl Workbook {
custom_lists: vec![],
cell_styles: vec![],
vba_project: None,
csv_delimiter: None,
preserved: None,
}
}
+3 -2
View File
@@ -295,7 +295,8 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted
}
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
/// converted to a number first; other text goes through the text section (or is returned as is).
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
/// section, or are returned as is.
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
if code.is_empty() {
return Ok(String::new());
@@ -305,7 +306,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
Value::Error(e) => return Err(e),
Value::Empty => 0.0,
Value::Number(n) => n,
Value::Bool(b) => return Ok(if b { "TRUE" } else { "FALSE" }.into()),
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
Some(n) => n,
None => return Ok(fmt.format_text(&t).text),
+3 -1
View File
@@ -555,7 +555,9 @@ fn analyze(raw: Raw) -> Section {
}
}
Tok::Slash => {
let next_ok = matches!(toks.get(i + 1), Some(Tok::Digit(_) | Tok::FixedDenom(_)));
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. }));
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
fraction_slash = true;
+101 -43
View File
@@ -138,7 +138,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out>
}
/// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder
/// takes all remaining digits.
/// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no
/// digits. A blank `?` pads with a space, and so does the thousands separator it would carry.
fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) {
let ds: Vec<char> = digits.chars().collect();
let mut rem = ds.len();
@@ -162,8 +163,14 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z
if take == 0 {
match ph {
'0' => push_digit('0', &mut rev, &mut pos),
_ if force_zero && leftmost && pos == 0 => push_digit('0', &mut rev, &mut pos),
'?' => rev.push(' '),
_ if force_zero && k == 0 => push_digit('0', &mut rev, &mut pos),
'?' => {
if thousands && pos > 0 && pos.is_multiple_of(3) {
rev.push(' ');
}
rev.push(' ');
pos += 1;
}
_ => {}
}
} else {
@@ -235,11 +242,14 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
}
let toks = &sec.toks;
let n = toks.len();
// Pieces replace the token's own output; `before` is inserted ahead of a token.
// Pieces replace the token's own output; `before` is inserted ahead of a token; `lits`
// replaces a literal's text (fractions blank the literals of a hidden part).
let mut pieces: Vec<String> = vec![String::new(); n];
let mut before: Vec<String> = vec![String::new(); n + 1];
let mut lits: Vec<Option<String>> = Vec::new();
if sec.fraction {
render_fraction(sec, x, &mut pieces);
lits = vec![None; n];
render_fraction(sec, x, &mut pieces, &mut lits);
} else {
let point = toks.iter().position(|t| matches!(t, Tok::Point));
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
@@ -321,7 +331,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
out.push(b);
}
match t {
Tok::Lit(s) => out.push(s),
Tok::Lit(s) => out.push(lits.get(i).and_then(Option::as_deref).unwrap_or(s)),
Tok::Percent => out.push("%"),
Tok::Fill(c) => out.fill(*c),
Tok::At | Tok::General | Tok::Date(_) | Tok::Comma => {}
@@ -370,29 +380,77 @@ fn approximate(x: f64, max_den: u64) -> (u64, u64) {
if err_b < err_a { (pb, qb) } else { (p1, q1) }
}
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
/// Display width of a token in a fraction section, for blanking a hidden part with spaces.
fn shown_width(t: &Tok) -> usize {
match t {
Tok::Lit(s) | Tok::FixedDenom(s) => s.chars().count(),
Tok::Digit(_) | Tok::Slash => 1,
_ => 0,
}
}
/// Replaces the literals in `range` with spaces (`spaces`) or nothing; placeholders and the bar
/// in it get the same treatment through `pieces`.
fn blank_range(toks: &[Tok], range: std::ops::Range<usize>, spaces: bool, pieces: &mut [String], lits: &mut [Option<String>]) {
for i in range {
let Some(t) = toks.get(i) else { break };
let s = if spaces { " ".repeat(shown_width(t)) } else { String::new() };
match t {
Tok::Lit(_) => {
if let Some(l) = lits.get_mut(i) {
*l = Some(s);
}
}
Tok::Digit(_) | Tok::Slash | Tok::FixedDenom(_) => {
if let Some(p) = pieces.get_mut(i) {
*p = s;
}
}
_ => {}
}
}
}
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
/// placeholders ending at the last one before the bar, the whole part is every placeholder
/// before it (literals between them stay), `sep` the literals between the two parts.
///
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
let toks = &sec.toks;
let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_)));
let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return };
let mut num_start = slash;
while num_start > 0 && matches!(toks.get(num_start - 1), Some(Tok::Digit(_))) {
let Some(num_last) = (0..slash).rev().find(|&i| is_digit(i)) else { return };
let mut num_start = num_last;
while num_start > 0 && is_digit(num_start - 1) {
num_start -= 1;
}
let whole_idx = digit_indices(toks, 0..num_start);
let num_idx = digit_indices(toks, num_start..slash);
let mut den_end = slash + 1;
let fixed_den: Option<u64> = match toks.get(slash + 1) {
Some(Tok::FixedDenom(s)) => {
den_end += 1;
Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000))
let num_idx = digit_indices(toks, num_start..num_last + 1);
let sep_start = whole_idx.last().map_or(num_start, |&w| w + 1);
let (fixed_den, den_start): (Option<u64>, usize) = match toks.get(slash + 1) {
Some(Tok::FixedDenom(s)) => (Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000)), slash + 1),
_ => {
let mut i = slash + 1;
while matches!(toks.get(i), Some(Tok::Lit(_))) {
i += 1;
}
(None, i)
}
_ => None,
};
if fixed_den.is_none() {
while matches!(toks.get(den_end), Some(Tok::Digit(_))) {
let mut den_end = den_start;
if fixed_den.is_some() {
den_end += 1;
} else {
while is_digit(den_end) {
den_end += 1;
}
}
let den_idx = digit_indices(toks, slash + 1..den_end);
let den_idx = digit_indices(toks, den_start..den_end);
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
let has_whole = !whole_idx.is_empty();
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
let (mut num, den) = match fixed_den {
@@ -407,39 +465,39 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
num %= den;
}
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
let blank = has_whole && num == 0;
fill_int(toks, &whole_idx, &whole_s, sec.thousands, blank, pieces);
let num_s = num.to_string();
let den_s = den.to_string();
if blank {
let width = |idx: &[usize]| idx.len();
let nw = width(&num_idx).max(1);
if let Some(&first) = num_idx.first()
&& let Some(p) = pieces.get_mut(first)
{
*p = " ".repeat(nw);
}
if let Some(p) = pieces.get_mut(slash) {
*p = " ".into();
}
let dw = if fixed_den.is_some() { den_s.len() } else { den_idx.len() };
if let Some(p) = pieces.get_mut(slash + 1) {
*p = " ".repeat(dw);
}
// Literals between the whole part and the numerator stay as they are.
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
if has_whole && num == 0 && !num_zero_ph {
fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces);
let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx);
blank_range(toks, sep_start..den_end, spaces, pieces, lits);
return;
}
fill_int(toks, &num_idx, &num_s, false, true, pieces);
if has_whole {
// A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one.
let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#')));
let force = whole_s.is_empty() && num == 0 && !units_hash;
fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces);
let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit())));
if !whole_shown {
let spaces = has_q(&whole_idx) || has_q(&num_idx);
blank_range(toks, sep_start..num_start, spaces, pieces, lits);
}
}
fill_int(toks, &num_idx, &num.to_string(), false, true, pieces);
if let Some(p) = pieces.get_mut(slash) {
*p = "/".into();
}
let den_s = den.to_string();
if fixed_den.is_some() {
if let Some(p) = pieces.get_mut(slash + 1) {
if let Some(p) = pieces.get_mut(den_start) {
*p = den_s;
}
} else {
// Denominator digits are left aligned; `?` pads with spaces after them.
let dc: Vec<char> = den_s.chars().collect();
// Denominator digits are zero padded to the `0` placeholders, then left aligned; `?`
// pads with spaces after them.
let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count();
let padded = format!("{den_s:0>zeros$}");
let dc: Vec<char> = padded.chars().collect();
let count = den_idx.len();
for (k, &ti) in den_idx.iter().enumerate() {
let s = if k + 1 == count {
+91
View File
@@ -122,6 +122,82 @@ fn fractions() {
assert_eq!(f("# ???/???", 0.5), " 1/2 ");
}
/// Fraction sections with literal text, as Excel renders them (observed `TEXT()` results).
#[test]
fn fractions_with_literals() {
let cases: &[(&str, f64, &str)] = &[
// Literals between the parts and around the bar.
(r"|#\:#/#|", 23.75, "|23:3/4|"),
(r"|#\:#=/=#|", 23.75, "|23:3=/=4|"),
(r"|#\:#=/=#|", -23.75, "-|23:3=/=4|"),
(r"|#\:? ?=/=#|", 23.75, "|2:3 3=/=4|"),
(r"|#-#-#\:#/#|", 23.75, "|-2-3:3/4|"),
// A whole part without digits drops its separator (spaces next to a `?`).
(r"|#\:#/#|", 0.75, "|3/4|"),
(r"|#\:#/#|", -0.75, "-|3/4|"),
(r"|#-#-#\:#/#|", 0.75, "|--3/4|"),
(r"|#\:#=/=#|", 0.75, "|3=/=4|"),
(r"|#\:0=/=?|", 0.75, "|3=/=4|"),
(r"|#\:?=/=#|", 0.75, "| 3=/=4|"),
(r"|?\:#=/=#|", 0.75, "| 3=/=4|"),
(r"|?\:0=/=0|", -0.75, "-| 3=/=4|"),
(r"|?\:?0=/=#|", 0.75, "| 3=/=4|"),
(r"|0\:#=/=#|", 0.75, "|0:3=/=4|"),
// A zero fraction hides separator, numerator, bar and denominator (spaces if any `?`).
(r"|#\:#/#|", 0.0, "|0|"),
(r"|#\:#/#|", 1.0, "|1|"),
(r"|#\:#/#|", -1.0, "-|1|"),
(r"|#-#-#\:#/#|", 0.0, "|--0|"),
(r"|#-#-#\:#/#|", 1.0, "|--1|"),
(r"|#\:#=/=0|", 0.0, "|0|"),
(r"|0\:#=/=#|", 1.0, "|1|"),
(r"|#\:#=/=?|", 0.0, "|0 |"),
(r"|#\:?=/=0|", 1.0, "|1 |"),
(r"|?\:#=/=#|", 0.0, "|0 |"),
(r"|?\:#=/=#|", -1.0, "-|1 |"),
(r"|#\:? ?=/=#|", 0.0, "|:0 |"),
(r"|#\:? ?=/=?|", 1.0, "|:1 |"),
// ... unless the numerator has a `0` placeholder: then it shows 0/1.
(r"|#\:0=/=#|", 0.0, "|0=/=1|"),
(r"|?\:0=/=?|", 0.0, "|0:0=/=1|"),
(r"|0\:0=/=0|", 0.0, "|0:0=/=1|"),
(r"|#\:0=/=#|", 1.0, "|1:0=/=1|"),
(r"|?\:0=/=#|", -1.0, "-|1:0=/=1|"),
(r"|#\:? ?0#/000", 0.0, "|:0 00/001"),
(r"|#\:? ?0#/000", 1.0, "|:1 00/001"),
// `0` denominator placeholders zero pad on the left.
(r"|#\:? ?0#/000", 23.75, "|2:3 03/004"),
(r"|#\:? ?#/#|", 23.75, "|2:3 3/4|"),
// Literal text before the bar: still an improper fraction.
(r"|#_#/#|", 3.75, "|15 /4|"),
(r"|#_#/#|", -0.75, "-|3 /4|"),
(r"|#_#/#|", 0.0, "|0 /1|"),
(r"|#_#/#|", -1.0, "-|1 /1|"),
(r"|#_#=/=?|", 3.75, "|15 =/=4|"),
(r"|#_?=/=0|", 0.0, "|0 =/=1|"),
(r"|#_?=/=?|", 1.0, "|1 =/=1|"),
];
for &(code, v, want) in cases {
assert_eq!(f(code, v), want, "{code} with {v}");
}
}
#[test]
fn question_mark_pads_thousands_separator() {
let cases: &[(&str, f64, &str)] = &[
("?,???????", 1234567.0, " 1,234,567"),
("?,????????", 1234567.0, " 1,234,567"),
("?,?????????", 1234567.0, " 1,234,567"),
("?,?????????", -1234567.0, "- 1,234,567"),
("?,??????", 1234567.0, "1,234,567"),
("?,????", 12345.0, "12,345"),
("0,000000000", 1234567.0, "0,001,234,567"),
];
for &(code, v, want) in cases {
assert_eq!(f(code, v), want, "{code} with {v}");
}
}
#[test]
fn literals_and_fill() {
assert_eq!(f("\"$\"#,##0.00", 3.0), "$3.00");
@@ -264,6 +340,21 @@ fn text_fn() {
assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into()));
assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value));
assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into()));
// Booleans go through the text section as TRUE/FALSE (observed Excel results).
let cases: &[(bool, &str, &str)] = &[
(true, ";;;@", "TRUE"),
(true, ";;;\"hi\"", "hi"),
(true, ";;;\"h\"\"I\"", "hI"),
(false, ";;;\\h\\i", "hi"),
(true, ";;;\\@@\\@", "@TRUE@"),
(false, ";;;-@-", "-FALSE-"),
(true, ";;;@-", "TRUE-"),
(false, ";;;-@-@-", "-FALSE-FALSE-"),
(true, "0.00", "TRUE"),
];
for &(b, code, want) in cases {
assert_eq!(text_function(&Value::Bool(b), code, S), Ok(want.into()), "{code} with {b}");
}
}
/// Deterministic xorshift for fuzzing.
+1
View File
@@ -28,6 +28,7 @@ zbus = "=5.19.0"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
epaint_default_fonts = { workspace = true }
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The desktop's preferred language for the first-run interface language.
+9 -3
View File
@@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(250.0);
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) {
app.run_or_alert("file.print", params.clone());
out.close = true;
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned());
}
let can_print = app.services.print_native.is_none() || app.printing.ready();
if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked()
&& app.commit_edit(0, 0, false, false)
{
app.print_now(params.clone());
out.close = app.services.print_native.is_none();
}
ui.add_space(16.0);
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
+47 -5
View File
@@ -14,7 +14,7 @@
//! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel`
//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled
//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}`
//! - `app.open {path}`, `app.save {path?}`, `app.quit`
//! - `app.open {path, password?}`, `app.save {path?}`, `app.quit`
use std::sync::mpsc::Sender;
@@ -184,6 +184,16 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
(
"ui.printSettings",
"Print Settings",
"{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting",
),
(
"ui.printSubmit",
"Submit Print Job",
"existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog",
),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
@@ -350,10 +360,17 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
"ui.screenshot" => return Outcome::Screenshot { path: s("path").map(str::to_string) },
"app.open" => {
let Some(path) = s("path") else { return err("missing `path`") };
app.open_path(path);
match app.message.take() {
Some((_, m)) => err(m),
None => ok(json!({"title": app.session.active().map(|d| d.display_title())})),
// Not `open_path`: that asks for a missing password in a dialog, and programmatic
// calls never open one. A password-protected workbook takes `password` instead.
let r = app.run("file.open", json!({"path": path, "password": s("password")}));
if let Ok(r) = &r {
app.after_open(Some(path), r);
}
app.after_engine();
match (r, app.message.take()) {
(Err(e), _) => err(crate::clean_error(&e)),
(Ok(_), Some((_, m))) => err(m),
(Ok(_), None) => ok(json!({"title": app.session.active().map(|d| d.display_title())})),
}
}
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
@@ -486,4 +503,29 @@ mod tests {
assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty());
assert_eq!(filter_commands(sample(), None).len(), 3);
}
#[test]
fn print_command_discovery_documents_settings_and_submission() {
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, crate::Services::default());
let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else {
panic!("command discovery must return immediately");
};
assert_eq!(reply["ok"], true);
let commands = reply["result"].as_array().unwrap();
for (id, expected_docs) in [
("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]),
("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]),
] {
let command = commands.iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["ui"], true);
let docs = command["params"].as_str().unwrap();
for expected in expected_docs {
assert!(docs.contains(expected), "{id}: missing {expected}");
}
}
assert!(!app.printing.busy(), "discovery must not submit a job");
assert!(app.backstage.is_none(), "discovery must not open settings");
}
}
+25
View File
@@ -236,6 +236,23 @@ impl Dialog {
vec![Password { key: "password", label: "Password:" }],
json!({"on": false}),
),
"openPassword" => {
// The file rides along in the values (`path` on the desktop, `name` and `base64`
// on the web) and goes back to `file.open` with the password.
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
// An empty password from the start, so OK on the untouched field says "incorrect"
// rather than asking for the password again.
let mut values = params.clone();
values["password"] = json!("");
Dialog::form(
"openPassword",
"Password",
"file.open",
vec![Note(format!("'{file}' is protected.")), Password { key: "password", label: "Password:" }],
values,
)
}
"unprotectSheet" => Dialog::form(
"unprotectSheet",
"Unprotect Sheet",
@@ -1101,12 +1118,20 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
open = false;
if d.name == "solver" {
app.open_dialog("solverResults", r);
} else if d.name == "openPassword" {
let path = d.values.get("path").and_then(Json::as_str).map(str::to_string);
app.after_open(path.as_deref(), &r);
}
}
// Data Analysis asks before replacing data in its output range.
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
d.values.insert("askOverwrite".into(), json!(true));
}
// A wrong password: say so, clear the field and stay open for another try.
Err(e) if d.name == "openPassword" => {
d.values.insert("password".into(), json!(""));
app.message = Some((d.title.clone(), crate::clean_error(&e)));
}
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
}
} else if confirm {
+1 -1
View File
@@ -213,7 +213,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
{
let mut end = toks[j].end;
let mut range = RangeRef::cell(c);
if let (Some(Tok::Colon), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok))
if let (Some(Tok::Colon | Tok::Trim(_)), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok))
&& let Some(c2) = CellRef::parse(w2)
{
range = RangeRef::new(c, c2);
+7
View File
@@ -392,6 +392,13 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
}
ui.label(egui::RichText::new(")").font(theme::ui_font(12.0)));
});
// The description too, so values an argument takes (DATEDIF's units, #363) show
// while it's typed; wrapped to the hint's width.
if let Some(desc) = crate::formula_locale::description(&name, ed.locale) {
ui.set_max_width(ui.min_rect().width().max(220.0));
ui.separator();
ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap());
}
}
});
});
+107 -16
View File
@@ -752,6 +752,27 @@ pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
}
}
/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so
/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the
/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows.
/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but
/// downward text at the top, where it starts reading.
pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 {
let rot = egui::emath::Rot2::from_angle(angle);
let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2()));
let x = match h {
HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0,
HAlign::Right => rect.right() - pad - indent - turned.width(),
_ => rect.left() + pad + indent,
};
let y = match v {
VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0,
VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(),
_ => rect.top() + pad,
};
pos2(x, y) - turned.min.to_vec2()
}
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction
/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index
@@ -934,6 +955,10 @@ pub(crate) fn paint_quadrant(
if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() {
continue;
}
// Align and clip header text inside the content area, away from the dropdown.
let filter_space = sh.filter_button_space(c) * z;
let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom()));
let rect = &text_rect;
let st = &lk.style;
let size = st.font.size * 96.0 / 72.0 * z;
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
@@ -1012,7 +1037,13 @@ pub(crate) fn paint_quadrant(
}
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
let mut clip = *rect;
if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
if filter_space == 0.0
&& !numeric
&& !matches!(v, Value::Number(_))
&& st.align.rotation.abs() != 90
&& w > rect.width() - 2.0 * pad
&& in_merge(c).is_none()
{
let mut right = rect.right();
let mut left = rect.left();
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
@@ -1060,13 +1091,7 @@ pub(crate) fn paint_quadrant(
} else {
galley.clone()
};
// Upward text grows up and right from the bottom-left corner; downward text grows down
// and left from its anchor, so that anchor sits one block-height in from the top-left.
let pos = if angle > 0.0 {
pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad)
} else {
pos2(rect.left() + pad, rect.bottom() - pad)
};
let pos = rotated_text_pos(*rect, galley.size(), angle, st.align.h, st.align.v, pad, indent);
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
shape.angle = angle;
cp.add(shape);
@@ -1170,8 +1195,10 @@ pub(crate) fn paint_quadrant(
pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
let rect = geo.cell_rect(sh, c);
let s = (rect.height() - 4.0).clamp(10.0, 18.0);
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
// Keep the button within the same header slot that AutoFit reserves.
let pad = 2.0 * geo.z;
let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0));
Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s))
}
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
@@ -2226,12 +2253,7 @@ fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> {
fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool {
let c = geo.cell_at(sh, p);
let header = sh.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && c.col >= af.range.start.col && c.col <= af.range.end.col)
|| sh
.tables
.iter()
.any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && c.col >= t.range.start.col && c.col <= t.range.end.col);
header && filter_button_rect(geo, sh, c).contains(p)
sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p)
}
fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::model::Anchor> {
@@ -3072,6 +3094,75 @@ mod tests {
let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z);
assert!(y_bottom_ovf >= rect.top());
}
/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui
/// draws a rotated `TextShape`.
fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect {
let rot = egui::emath::Rot2::from_angle(angle);
let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v);
Rect::from_points(&pts)
}
#[test]
fn rotated_text_box_follows_the_cell_alignment() {
// A tall, narrow merged block (one 3-character column over four 60pt rows).
let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0));
let size = vec2(90.0, 15.0);
let pad = 2.5;
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
for deg in [90i16, -90, 45, -45] {
let angle = -(deg as f32).to_radians();
for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] {
for h in [HAlign::Left, HAlign::Center, HAlign::Right] {
let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle);
let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}");
assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}");
assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}");
// A narrow column can't hold a 45° line across: only check where it fits.
let fits_x = bb.width() <= rect.width() - 2.0 * pad;
match h {
HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"),
HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"),
_ => assert!(near(bb.center().x, rect.center().x), "{what}"),
}
if fits_x {
assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}");
}
match v {
VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"),
VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"),
// Bottom is the default: downward text keeps starting at the top, as before.
_ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"),
_ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"),
}
}
}
}
}
#[test]
fn upright_text_with_default_alignment_keeps_its_place() {
let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0));
let size = vec2(90.0, 15.0);
let pad = 2.5;
let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01;
// Upward text starts at the bottom-left corner, downward text one block-height in from the top-left.
let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0);
assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}");
let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0);
assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}");
}
#[test]
fn upward_text_with_middle_align_is_centred_in_a_merged_block() {
// Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom.
let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0));
let size = vec2(90.0, 15.0);
let angle = -std::f32::consts::FRAC_PI_2;
let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle);
assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}");
assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}");
}
}
#[cfg(test)]
+13 -1
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
mod cs;
mod de;
mod es;
mod fr;
mod ko;
mod pt;
mod ru;
@@ -31,10 +32,11 @@ pub enum Language {
PtBr,
De,
Cs,
Fr,
}
impl Language {
pub const ALL: [Self; 9] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs];
pub const ALL: [Self; 10] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs, Self::Fr];
/// The language's own name, shown in the switcher.
pub fn name(self) -> &'static str {
@@ -48,6 +50,7 @@ impl Language {
Self::PtBr => "Português (Brasil)",
Self::De => "Deutsch",
Self::Cs => "Čeština",
Self::Fr => "Français",
}
}
@@ -63,6 +66,7 @@ impl Language {
Self::PtBr => "pt",
Self::De => "de",
Self::Cs => "cs",
Self::Fr => "fr",
}
}
@@ -81,6 +85,7 @@ impl Language {
"pt" => Some(Self::PtBr),
"de" => Some(Self::De),
"cs" => Some(Self::Cs),
"fr" => Some(Self::Fr),
_ => None,
}
}
@@ -117,6 +122,7 @@ impl Language {
Self::PtBr => pt::translate(text),
Self::De => de::translate(text),
Self::Cs => cs::translate(text),
Self::Fr => fr::translate(text),
};
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
@@ -580,6 +586,7 @@ mod tests {
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
}
assert_eq!(Language::Ja.tr("Data"), "データ");
assert_eq!(Language::Zh.tr("Data"), "数据");
@@ -591,6 +598,8 @@ mod tests {
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::De.tr("Data"), "Daten");
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Fr.tr("Data"), "Données");
assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::PtBr.tr("Date"), "Data");
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
@@ -642,6 +651,9 @@ mod tests {
assert_eq!(Language::from_tag("cs-CZ"), Some(Language::Cs));
assert_eq!(Language::from_tag("cs_CZ.UTF-8"), Some(Language::Cs));
assert_eq!(Language::parse("cs"), Some(Language::Cs));
assert_eq!(Language::from_tag("fr-FR"), Some(Language::Fr));
assert_eq!(Language::from_tag("fr_CA"), Some(Language::Fr));
assert_eq!(Language::parse("fr"), Some(Language::Fr));
}
#[test]
+388
View File
@@ -0,0 +1,388 @@
//! French translations for the interface strings in i18n.rs.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Different First Page" => "Première page différente",
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
"Paste" => "Coller",
"Paste Values" => "Coller les valeurs",
"Paste Formulas" => "Coller les formules",
"Paste Formatting" => "Coller la mise en forme",
"Transpose" => "Transposer",
"Paste Link" => "Coller la liaison",
"Paste Special…" => "Collage spécial…",
"Insert Copied Cells" => "Insérer les cellules coupées",
"Insert Copied Cells…" => "Coller en insérant",
"Insert Cut Cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Insert Paste" => "Taille de l’interface",
"Cut (⌘X)" => "Couper (⌘X)",
"Copy (⌘C)" => "Copier (⌘C)",
"Format Painter (double-click to keep it on)" => "Reproduire la mise en forme (double-clic pour la conserver)",
"Bold (⌘B)" => "Gras (⌘B)",
"Italic (⌘I)" => "Italique (⌘I)",
"Underline (⌘U)" => "Souligné (⌘U)",
"Double Underline" => "Double souligné",
"Strikethrough" => "Barré",
"Underline" => "Souligné",
"Increase Font Size" => "Augmenter la taille de la police",
"Decrease Font Size" => "Réduire la taille de la police",
"Wrap Text" => "Renvoyer à la ligne",
"Merge & Center" => "Fusionner et centrer",
"Merge Cells" => "Fusionner les cellules",
"Merge Across" => "Fusionner horizontalement",
"Unmerge Cells" => "Dissocier les cellules",
"Fill Color" => "Couleur de remplissage",
"Font Color" => "Couleur de police",
"Borders" => "Bordures",
"Top\nAlign" => "Aligner en haut",
"Middle\nAlign" => "Aligner au centre",
"Bottom\nAlign" => "Aligner en bas",
"Left" => "Gauche",
"Right" => "Droite",
"Center" => "Centré",
"Horizontal" => "Horizontal",
"Vertical Text" => "Texte vertical",
"Decrease Indent" => "Diminuer le retrait",
"Increase Indent" => "Augmenter le retrait",
"Orientation" => "Orientation",
"Page Layout|Orientation" => "Orientation",
"Angle Counterclockwise" => "Inclinaison vers la gauche",
"Angle Clockwise" => "Inclinaison vers la droite",
"Rotate Text Up" => "Pivoter le texte vers le haut",
"Rotate Text Down" => "Pivoter le texte vers le bas",
"Accounting Number Format" => "Format de nombre Comptabilité",
"Percent Style" => "Style de pourcentage",
"Comma Style" => "Style de virgule",
"Increase Decimal" => "Ajouter une décimale",
"Decrease Decimal" => "Retirer une décimale",
"More Accounting Formats…" => "Autres formats comptables…",
"Conditional\nFormatting" => "Mise en forme conditionnelle",
"Conditional Formatting" => "Mise en forme conditionnelle",
"Format\nas Table" => "Mettre sous forme de tableau",
"Cell\nStyles" => "Styles de cellules",
"Format Cells…" => "Format de cellules…",
"Format Cell Alignment…" => "Format d’alignement de cellule…",
"Format\nPane" => "Volet Format",
"Format" => "Format",
"Insert" => "Insertion",
"Insert Cells…" => "Insérer des cellules…",
"Delete" => "Supprimer",
"Delete Cells…" => "Supprimer des cellules…",
"Insert Sheet Rows" => "Insérer des lignes de feuille",
"Insert Sheet Columns" => "Insérer des colonnes de feuille",
"Delete Sheet Rows" => "Supprimer des lignes de feuille",
"Delete Sheet Columns" => "Supprimer des colonnes de feuille",
"Row Height…" => "Hauteur de ligne…",
"AutoFit Row Height" => "Ajustement automatique de la hauteur de ligne",
"Column Width…" => "Largeur de colonne…",
"AutoFit Column Width" => "Ajustement automatique de la largeur de colonne",
"Default Width…" => "Largeur par défaut…",
"Hide Rows" => "Masquer les lignes",
"Unhide Rows" => "Afficher les lignes",
"Hide Columns" => "Masquer les colonnes",
"Unhide Columns" => "Afficher les colonnes",
"Lock Cell" => "Verrouiller la cellule",
"Clear" => "Effacer",
"Clear All" => "Effacer tout",
"Clear Formats" => "Effacer les formats",
"Clear Contents" => "Effacer le contenu",
"Clear Comments and Notes" => "Effacer les commentaires et les notes",
"Clear Hyperlinks" => "Effacer les liens hypertexte",
"AutoSum" => "Somme automatique",
"AutoSum (⌘⇧T)" => "Somme automatique (⌘⇧T)",
"Flash\nFill" => "Remplissage instantané",
"Flash Fill" => "Remplissage instantané",
"Fill" => "Remplir",
"Clear Print Area" => "Effacer la zone d’impression",
"Find &\nSelect" => "Rechercher et sélectionner",
"Find…" => "Rechercher…",
"Replace…" => "Remplacer…",
"Go To…" => "Atteindre…",
"Go To Special…" => "Atteindre spécial…",
"PivotTable" => "Tableau croisé dynamique",
"Recommended\nCharts" => "Graphiques recommandés",
"Table" => "Tableau",
"Pictures" => "Images",
"Shapes" => "Formes",
"Icons" => "Icônes",
"Text\nBox" => "Zone de texte",
"Comment" => "Commentaire",
"New\nComment" => "Nouveau commentaire",
"New Note" => "Nouvelle note",
"Show/Hide Note" => "Afficher/masquer la note",
"Header &\nFooter" => "En-tête et pied de page",
"Text to\nColumns" => "Convertir",
"Link" => "Lien",
"Symbol" => "Symbole",
"Sparklines" => "Sparklines",
"Checkbox" => "Cliquez pour ajouter des données",
"Insert checkboxes in the selected cells" => "Mots-clés",
"Header & Footer" => "En-tête et pied de page",
"Draw with ink" => "Dessiner avec l’encre",
"Eraser" => "Gomme",
"Ink to\nShape" => "Encre vers forme",
"Select\nObjects" => "Sélection d’objets",
"Themes" => "Thèmes",
"Margins" => "Marges",
"Size" => "Taille",
"Print\nArea" => "Zone d’impression",
"Set Print Area" => "Définir la zone d’impression",
"Breaks" => "Sauts",
"Insert Page Break" => "Insérer un saut de page",
"Remove Page Break" => "Supprimer le saut de page",
"Reset All Page Breaks" => "Réinitialiser tous les sauts de page",
"Print\nTitles" => "Titres à imprimer",
"Sheet\nRight-to-Left" => "Afficher la barre de formule",
"Sheet Right-to-Left" => "Feuilles",
"Narrow" => "Étroites",
"Wide" => "Larges",
"Normal" => "Normales",
"Portrait" => "Portrait",
"Landscape" => "Paysage",
"Custom Margins…" => "Marges personnalisées…",
"Insert\nFunction" => "Insérer une fonction",
"Name\nManager" => "Gestionnaire de noms",
"Define Name" => "Définir un nom",
"Use in Formula" => "Utiliser dans la formule",
"Create from Selection" => "Créer à partir de la sélection",
"Trace Precedents" => "Repérer les antécédents",
"Trace Dependents" => "Repérer les dépendants",
"Remove Arrows" => "Supprimer les flèches",
"Show Formulas" => "Afficher les formules",
"Error Checking" => "Vérification des erreurs",
"Evaluate Formula" => "Évaluation de formule",
"Watch Window" => "Fenêtre Espion",
"Calculation\nOptions" => "Options de calcul",
"Calculate Now" => "Calculer maintenant",
"Calculate Sheet" => "Calculer la feuille",
"Automatic" => "Automatique",
"Automatic Except for Data Tables" => "Automatique sauf pour les tables de données",
"Manual" => "Manuel",
"Sort &\nFilter" => "Trier et filtrer",
"Sort" => "Trier",
"Sort A to Z" => "Trier de A à Z",
"Sort Z to A" => "Trier de Z à A",
"Custom Sort…" => "Tri personnalisé…",
"Filter" => "Filtrer",
"Reapply" => "Réappliquer",
"Get Data\n(Text/CSV)" => "Obtenir des données (Texte/CSV)",
"Remove\nDuplicates" => "Supprimer les doublons",
"Data\nValidation" => "Validation des données",
"Data Validation" => "Validation des données",
"Data Validation…" => "Validation des données…",
"Circle Invalid Data" => "Cerner les données non valides",
"Clear Validation" => "Effacer la validation",
"Group" => "Grouper",
"Ungroup" => "Dissocier",
"Show Detail" => "Titre",
"Hide Detail" => "Insérer les cellules copiées",
"Subtotal" => "Sous-total",
"Data\nAnalysis" => "Description",
"Data Analysis" => "Analyse\nde données",
"Solver" => "Page web (.html)",
"What-If\nAnalysis" => "Analyse scénarios",
"Data Table…" => "Table de données…",
"Scenario Manager…" => "Gestionnaire de scénarios…",
"Goal Seek…" => "Valeur cible…",
"Forecast\nSheet" => "Aller au pied de page",
"Spelling" => "Orthographe",
"Check\nAccessibility" => "Vérifier l’accessibilité",
"Threaded Comments" => "Commentaires threadés",
"Show\nComments" => "Afficher les commentaires",
"Previous" => "Imprimer les feuilles actives",
"Next" => "Aucun classeur récent pour le moment.",
"Previous Comment" => "Imprimer tout le classeur",
"Next Comment" => "Aucun classeur n’est ouvert.",
"Show All Notes" => "Objet",
"Convert to Comments" => "Créer un PDF",
"Protect\nWorkbook" => "Protéger le classeur",
"Allow Edit\nRanges" => "Animations",
"Allow Edit Ranges" => "Autoriser la\nmodification des plages",
"Unprotect\nSheet" => "Ôter la protection de la feuille",
"Protect Sheet…" => "Protéger la feuille…",
"Comments" => "Commentaires",
"Notes" => "Notes",
"Page Break\nPreview" => "Aperçu des sauts de page",
"Page\nLayout" => "Mise en page",
"Formula Bar" => "Barre de formule",
"Gridlines" => "Quadrillage",
"Headings" => "En-têtes",
"Zoom" => "Zoom",
"100%" => "100%",
"Zoom to\nSelection" => "Zoom sur la sélection",
"Freeze\nPanes" => "Figer les volets",
"Freeze Panes" => "Figer les volets",
"Freeze Top Row" => "Figer la ligne supérieure",
"Freeze First Column" => "Figer la première colonne",
"Unfreeze Panes" => "Libérer les volets",
"Dark Mode" => "Mode sombre",
"Display theme" => "Thème d’affichage",
"Animations" => "Auteur",
"Glide the selection outline to the cell you move to instead of jumping there" => "Aller à l’en-tête",
"System" => "Système",
"Light" => "Clair",
"Dark" => "Sombre",
"Interface language" => "Langue de l’interface",
"Interface size" => "Emplacement",
"Auto" => "Retour",
"Drag to freeze columns" => "Faites glisser pour figer les lignes",
"Drag to freeze rows" => "Activer le calcul itératif",
"Command\nPalette" => "Palette de commandes",
"Agent\nControl" => "Contrôle de l’agent",
"Action\nJournal" => "Journal des actions",
"About\nGridCraft" => "À propos de GridCraft",
"Share" => "Partager",
"Add Chart\nElement" => "Ajouter un élément de graphique",
"Change\nChart Type" => "Modifier le type de graphique",
"Switch\nRow/Column" => "Permuter ligne/colonne",
"Select\nData" => "Nom de la\nfeuille",
"Data Labels: None" => "Étiquettes de données : Aucune",
"Data Labels: Show" => "Étiquettes de données : Afficher",
"Legend: None" => "Légende : Aucune",
"Legend: Bottom" => "Légende : Bas",
"Legend: Top" => "Légende : Haut",
"Legend: Right" => "Légende : Droite",
"Gridlines: None" => "Quadrillage : Aucun",
"Gridlines: Show" => "Quadrillage : Afficher",
"Chart\nTitle" => "Titre du graphique",
"Delete\nChart" => "Supprimer le graphique",
"Convert\nto Range" => "Convertir en plage",
"Table\nStyles" => "Styles de tableau",
"Top 10 Items" => "10 premiers éléments",
"Top 10%" => "10 % les plus élevés",
"Bottom 10 Items" => "10 derniers éléments",
"Bottom 10%" => "10 % les moins élevés",
"Above Average" => "Au-dessus de la moyenne",
"Below Average" => "En dessous de la moyenne",
"Clear Rules from Entire Sheet" => "Effacer les règles de la feuille entière",
"Clear Rules from Selected Cells" => "Effacer les règles des cellules sélectionnées",
"Constants" => "Constantes",
"Data Bars" => "Barres de données",
"Color Scales" => "Échelles de couleurs",
"Icon Sets" => "Jeux d’icônes",
"Home" => "Accueil",
"Draw" => "Dessin",
"Page Layout" => "Mise en page",
"Formulas" => "Formules",
"Data" => "Données",
"Review" => "Révision",
"View" => "Affichage",
"Automate" => "Automatiser",
"Table Design" => "Création de tableau",
"Chart Design" => "Création de graphique",
"Ready" => "Prêt",
"Enter" => "Entrée",
"Edit" => "Modifier",
"Point" => "Point",
"Calculate" => "Calculer",
"Select destination and press Enter or choose Paste" => "Sélectionnez la destination et appuyez sur Entrée ou choisissez Coller",
"General" => "Standard",
"Number" => "Nombre",
"Currency" => "Monétaire",
"Accounting" => "Comptabilité",
"Date" => "Date",
"Time" => "Heure",
"Percentage" => "Pourcentage",
"Fraction" => "Fraction",
"Scientific" => "Scientifique",
"Text" => "Texte",
"Special" => "Spécial",
"Custom" => "Personnalisée",
"Short Date" => "Date courte",
"Long Date" => "Date longue",
"More Number Formats…" => "Autres formats numériques…",
"Financial" => "Financières",
"Logical" => "Logiques",
"Date &\nTime" => "Date et heure",
"Lookup &\nReference" => "Recherche et référence",
"Math &\nTrig" => "Maths et trigonométrie",
"More\nFunctions" => "Autres fonctions",
"Sheet Options|View" => "Affichage",
"Print" => "Impression",
"Click to add data" => "Fermer",
"Add header" => "Autoriser la modification des plages",
"Add footer" => "Ajouter un en-tête",
"(none)" => "(aucun)",
"Header" => "Informations",
"Footer" => "Feuille de\nprévision",
"Page\nNumber" => "Page précédente",
"Number\nof Pages" => "Général",
"Current\nDate" => "Heure\nactuelle",
"Current\nTime" => "Analyse de données",
"File\nPath" => "Ajuster la feuille sur une seule page",
"File\nName" => "Chemin\ndu fichier",
"Sheet\nName" => "Afficher le détail",
"Go to\nHeader" => "Masquer le détail",
"Go to\nFooter" => "En-tête",
"Page Number" => "Numéro\nde page",
"Number of Pages" => "Options",
"Current Date" => "Heure actuelle",
"Current Time" => "Date\nactuelle",
"File Path" => "Nom du\nfichier",
"File Name" => "Chemin du fichier",
"Sheet Name" => "Feuille de\ndroite à gauche",
"Go to Header" => "Aller à\nl’en-tête",
"Go to Footer" => "Aller au\npied de page",
"Ruler" => "Enregistrer sous",
"File" => "Nom du fichier",
"Back" => "Classeur vide",
"New" => "Page suivante",
"Open" => "Enregistrement",
"Info" => "Insérer les cellules copiées…",
"Save" => "Type de fichier",
"Save As" => "Enregistrer la feuille sous forme de document PDF en conservant sa mise en page.",
"Export" => "Fichier",
"Close" => "Société",
"Options" => "PDF (*.pdf)",
"Blank workbook" => "Parcourir…",
"Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"No recent workbooks yet." => "Rien à imprimer.",
"More workbooks" => "Commentaire suivant",
"Browse…" => "CSV (séparé par des virgules)",
"Not saved yet" => "Nombre\nde pages",
"Location" => "Nouveau",
"Sheets" => "Solveur",
"Protect Workbook" => "Enregistrer",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "Afficher toutes les notes",
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "Suivant",
"Properties" => "Règle",
"Title" => "Titre",
"Subject" => "Calcul du classeur",
"Author" => "Automatique",
"Company" => "Convertir en commentaires",
"Keywords" => "Plus de classeurs",
"Description" => "Télécharger",
"No workbook is open." => "Nombre de pages",
"Save as type" => "Enregistrer le classeur sous forme de page web.",
"Excel Workbook (*.xlsx)" => "Exporter",
"CSV UTF-8 (Comma delimited) (*.csv)" => "Les paramètres de calcul sont enregistrés avec le classeur.",
"Web Page (*.html)" => "Page web (*.html)",
"PDF (*.pdf)" => "Page {page} sur {pages}",
"Download" => "Faites glisser pour figer les colonnes",
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "Ajouter un pied de page",
"Save the workbook under another name or in another folder." => "Feuille de droite à gauche",
"Print Active Sheets" => "Protéger le classeur",
"Print Entire Workbook" => "Récents",
"Fit Sheet on One Page" => "Pied de page",
"Page Setup…" => "Précédent",
"Page {page} of {pages}" => "Commentaire précédent",
"Nothing to print." => "Ouvrir",
"Previous page" => "Propriétés",
"Next page" => "Pas encore enregistré",
"Create PDF" => "Date actuelle",
"Save the sheet as a PDF document that keeps its page layout." => "Sélectionner\nles données",
"Change File Type" => "Case à cocher",
"CSV (Comma delimited)" => "CSV UTF-8 (séparé par des virgules) (*.csv)",
"Save the active sheet as comma-separated text." => "Enregistrer le classeur sous un autre nom ou dans un autre dossier.",
"Web Page (.html)" => "Page web (.html)",
"Save the workbook as a web page." => "Nom de la feuille",
"Options|General" => "Numéro de page",
"Options|Save" => "Mise en page…",
"Show formula bar" => "Page web (*.html)",
"Save .xlsx workbooks automatically after every change" => "Enregistrer la feuille active sous forme de texte séparé par des virgules.",
"Workbook calculation" => "Calcul du classeur",
"Enable iterative calculation" => "Classeur Excel (*.xlsx)",
"Calculation settings are saved with the workbook." => "Modifier le type de fichier",
_ => return None,
})
}
+35 -19
View File
@@ -1,12 +1,23 @@
//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions.
//!
//! The sequence table lives in [`keytip_table`]: the match below only walks the prefix.
use egui::{Context, Event, Key, Modifiers, os::OperatingSystem};
pub mod keytip_table;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Home,
Tab(&'static str),
Command(&'static str),
Dialog(&'static str),
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
Pending(&'static str),
/// Route the keytip to the matching ribbon control: open its dropdown or run its click
/// handler (which carries the right params), instead of firing the engine command with
/// empty params.
Control(&'static str),
}
#[derive(Default)]
@@ -85,28 +96,33 @@ impl KeyTips {
} else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) {
handled = true;
if *key == Key::Escape {
if let Some(prefix) = self.prefix.as_mut()
&& prefix.pop().is_none()
{
self.prefix = None;
// Escape pops one level; at a single-letter level it
// returns to the armed root (empty prefix).
if let Some(prefix) = self.prefix.as_mut() {
if prefix.is_empty() {
self.prefix = None;
} else {
prefix.pop();
}
}
} else if key.name().len() == 1 {
let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name());
match next.as_str() {
"H" => {
self.prefix = Some(next);
actions.push(Action::Home);
match keytip_table::lookup(&next) {
Some(keytip_table::Entry::Tab(tab)) => {
// A tab with deeper sequences stays as an active level
// (its group badges show); a terminal unit like "JI"
// finishes and clears the prefix.
self.prefix = keytip_table::has_deeper(&next).then_some(next);
actions.push(Action::Tab(tab));
}
"HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next),
"HAR" => self.finish(Action::Command("home.alignRight"), &mut actions),
"HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions),
"HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions),
"HAT" => self.finish(Action::Command("home.alignTop"), &mut actions),
"HAM" => self.finish(Action::Command("home.alignMiddle"), &mut actions),
"HAB" => self.finish(Action::Command("home.alignBottom"), &mut actions),
"HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions),
"HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions),
_ => {}
Some(keytip_table::Entry::Continue) => self.prefix = Some(next),
Some(keytip_table::Entry::Finish(action)) => self.finish(action, &mut actions),
// No complete sequence, but deeper ones start here: wait for
// more input (Excel's two-letter units like Draw's "JI").
None if keytip_table::has_deeper(&next) => self.prefix = Some(next),
// Unknown letters are consumed while a prefix is active but
// leave it unchanged, as Excel does.
None => {}
}
}
}
+234
View File
@@ -0,0 +1,234 @@
//! The keytip sequence table: every Excel-style Alt access sequence and what it does.
//!
//! The matcher in [`super::KeyTips::process`] and the badge painter in `ribbon.rs` both read
//! [`SEQUENCES`], so a sequence is added in exactly one place: `keytips.json`.
//!
//! `keytips.json` is the human-edited source of truth (grouped, commented, one line per
//! sequence; `pending` reserves sequences for commands that do not exist yet). `generated.rs`
//! is produced from it by `cargo xtask keytips` — edit the JSON, not the generated file.
pub(crate) use super::Action;
mod generated;
pub use generated::SEQUENCES;
/// What a fully-matched sequence does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Entry {
/// Switch to this ribbon tab and keep the prefix: the tab's own badges take over.
Tab(&'static str),
/// Keep the prefix: this sequence is a group-level badge; deeper sequences finish it.
Continue,
/// Complete the sequence: run the action and clear the prefix.
Finish(Action),
}
/// Look up a complete sequence.
pub fn lookup(sequence: &str) -> Option<Entry> {
SEQUENCES.iter().find(|(seq, _)| *seq == sequence).map(|(_, entry)| *entry)
}
/// True if `prefix` is a strict prefix of any known sequence (badges wait for more keys).
pub fn has_deeper(prefix: &str) -> bool {
SEQUENCES.iter().any(|(seq, _)| seq.len() > prefix.len() && seq.starts_with(prefix))
}
/// The sequence that switches to `tab_name`, if that tab has one (may be a multi-letter unit
/// like Draw's "JI").
pub fn tab_sequence(tab_name: &str) -> Option<&'static str> {
SEQUENCES.iter().find_map(|(seq, entry)| match entry {
Entry::Tab(name) if *name == tab_name => Some(*seq),
_ => None,
})
}
/// True if `seq` is a group level: an explicit Continue entry, or an implicit level that only
/// exists because deeper sequences start with it (like "J" before Draw's "JI").
pub fn is_group_level(seq: &str) -> bool {
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
}
fn matches_target(action: &Action, target: &str) -> bool {
match action {
Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target,
Action::Dialog(name) => format!("dialog:{name}") == target,
Action::Tab(_) => false,
}
}
/// All sequences that finish by running `target` (a command id or `dialog:<name>`).
pub fn sequences_for(target: &str) -> Vec<&'static str> {
SEQUENCES
.iter()
.filter_map(|(seq, entry)| match entry {
Entry::Finish(action) if matches_target(action, target) => Some(*seq),
_ => None,
})
.collect()
}
/// Group-level letters for a menu anchor: sequences one key past `prefix` that continue toward
/// any of `targets` (the menu's items) — the badge shown on the button that opens the menu.
pub fn group_extension(prefix: &str, targets: &[&str]) -> Vec<&'static str> {
let mut out = Vec::new();
for target in targets {
for seq in sequences_for(target) {
if seq.len() <= prefix.len() + 1 || !seq.starts_with(prefix) {
continue;
}
// The intermediate one key past the prefix is the group badge, if it is a
// group-level sequence (explicit or implicit) rather than the finish itself.
let candidate = &seq[..prefix.len() + 1];
if is_group_level(candidate) && !out.contains(&candidate) {
out.push(candidate);
}
}
}
out
}
/// The badge for a menu item: its finish sequence exactly one key below the current prefix.
pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> {
sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
}
/// Badge sequences for a command control: every sequence for `target` that continues from the
/// current prefix (the painter strips the prefix, so the remaining letters show).
pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> {
if prefix.is_empty() {
return Vec::new(); // empty prefix shows only tab badges
}
sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect()
}
/// The badge for a single control: the rest of its finish sequence when it has exactly one, or
/// the common group letter when several sequences land on the same control (menu anchors like
/// Borders, whose HBA..HBW variants all share "HB").
pub fn control_badge(prefix: &str, target: &str) -> Option<&'static str> {
if prefix.is_empty() {
return None; // empty prefix shows only tab badges
}
let seqs: Vec<&'static str> = sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect();
match seqs.as_slice() {
[] => None,
[single] => Some(single),
many => {
let common = |a: &str, b: &str| a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count();
let lcp = many.iter().skip(1).fold(many[0].len(), |acc, s| acc.min(common(many[0], s)));
(lcp > prefix.len()).then_some(&many[0][..lcp])
}
}
}
/// The single badge for a menu anchor: the direct finish one key past the prefix, or the
/// group-level candidate that prefixes the most of `targets`' sequences. Anchors get one
/// badge, so overlapping groups (e.g. Pictures' "NP" vs SmartArt's "NM") resolve to the
/// dominant one.
pub fn anchor_badge(prefix: &str, targets: &[&str]) -> Option<&'static str> {
if prefix.is_empty() {
return None; // empty prefix shows only tab badges
}
let all: Vec<&'static str> = targets.iter().flat_map(|t| sequences_for(t)).filter(|s| s.len() > prefix.len() && s.starts_with(prefix)).collect();
if all.is_empty() {
return None;
}
let depth = prefix.len() + 1;
let mut candidates: Vec<&'static str> = all.iter().filter(|s| s.len() == depth).copied().collect();
if candidates.is_empty() {
for s in &all {
let c = &s[..depth.min(s.len())];
if is_group_level(c) && !candidates.contains(&c) {
candidates.push(c);
}
}
}
let mut best: Option<(&'static str, usize)> = None;
for c in candidates {
let score = all.iter().filter(|s| s.starts_with(c)).count();
if best.is_none_or(|(_, b)| score > b) {
best = Some((c, score));
}
}
best.map(|(c, _)| c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_letters_exist_for_standard_tabs() {
for (tab, letter) in [
("Home", "H"),
("Insert", "N"),
("Page Layout", "P"),
("Formulas", "M"),
("Data", "A"),
("Review", "R"),
("View", "W"),
("Draw", "JI"), // two-letter unit, shown as one badge
] {
assert_eq!(tab_sequence(tab), Some(letter), "tab {tab}");
}
assert_eq!(tab_sequence("Automate"), None);
}
#[test]
fn sequences_for_finds_command_and_dialog_targets() {
assert_eq!(sequences_for("home.alignRight"), vec!["HAR"]);
assert_eq!(sequences_for("dialog:pasteSpecial"), vec!["HVS", "ES"]);
assert_eq!(sequences_for("home.autofitColumnWidth"), vec!["HOI", "OCA"]);
}
#[test]
fn implicit_levels_wait_for_deeper_sequences() {
assert!(has_deeper("J"), "JI is reachable only through the implicit J level");
assert!(is_group_level("J"), "implicit levels are group levels");
assert!(is_group_level("HV"), "explicit Continue levels are group levels");
assert!(!is_group_level("H"), "a terminal tab is not a group level");
assert!(!is_group_level("HVS"), "a finish is not a group level");
assert!(!has_deeper("X"));
}
#[test]
fn group_extension_paints_only_continue_levels() {
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
assert!(group_extension("", &["dialog:pasteSpecial"]).is_empty(), "tab letters paint on tabs, not buttons");
assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item");
}
#[test]
fn item_letter_is_one_level_deep() {
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
assert_eq!(item_letter("HV", "edit.pasteSpecial"), Some("HVP"));
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
}
#[test]
fn control_badges_require_a_known_prefix() {
assert!(control_badges("", "home.alignRight").is_empty(), "empty prefix shows only tab badges");
assert_eq!(control_badges("HA", "home.alignRight"), vec!["HAR"]);
assert!(control_badges("HZ", "home.alignRight").is_empty(), "unknown prefixes show nothing");
}
#[test]
fn control_badge_shows_the_rest_or_the_group_letter() {
assert_eq!(control_badge("H", "home.bold"), Some("H1"), "a unique sequence paints its full rest");
assert_eq!(control_badge("H", "home.fontColor"), Some("HFC"));
assert_eq!(control_badge("H", "home.borders"), Some("HB"), "many variants collapse to the group letter");
assert!(control_badge("", "home.bold").is_none(), "empty prefix shows only tab badges");
assert!(control_badge("HZ", "home.bold").is_none(), "unknown prefixes show nothing");
assert!(control_badge("H", "view.zoom").is_none(), "diverging sequences at the next letter show nothing");
}
#[test]
fn anchor_badge_resolves_to_the_dominant_group() {
assert_eq!(anchor_badge("H", &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]), Some("HV"));
assert_eq!(anchor_badge("P", &["pageLayout.orientation"]), Some("PO"), "a direct finish paints itself");
assert_eq!(anchor_badge("N", &["insert.picture"]), Some("NP"), "NP dominates the lone NM (SmartArt) entry");
assert_eq!(anchor_badge("H", &["table.style"]), Some("HT"));
assert!(anchor_badge("", &["edit.paste"]).is_none(), "empty prefix shows only tab badges");
assert!(anchor_badge("H", &["view.zoom"]).is_none(), "no sequences under this prefix");
}
}
@@ -0,0 +1,373 @@
//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.
use super::{Action, Entry};
/// All known keytip sequences. Intermediate levels are implicit: the matcher waits
/// whenever a pressed letter starts a deeper sequence, so only terminal entries are
/// listed here. See keytip_table.rs for the helpers that read this table.
pub const SEQUENCES: &[(&str, Entry)] = &[
("H", Entry::Tab("Home")),
("N", Entry::Tab("Insert")),
("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors.
("P", Entry::Tab("Page Layout")),
("M", Entry::Tab("Formulas")),
("A", Entry::Tab("Data")),
("R", Entry::Tab("Review")),
("W", Entry::Tab("View")),
("E", Entry::Tab("Home")), // Legacy Edit-menu path (Excel 2003); leads to the same Home menus.
("O", Entry::Tab("Home")), // Legacy Format-menu path (Excel 2003); leads to the same Home menus.
("HAR", Entry::Finish(Action::Command("home.alignRight"))),
("HAT", Entry::Finish(Action::Command("home.alignTop"))), // Excel: Excel: Top Align
("HAM", Entry::Finish(Action::Command("home.alignMiddle"))), // Excel: Excel: Middle Align
("HAB", Entry::Finish(Action::Command("home.alignBottom"))), // Excel: Excel: Bottom Align
("HAL", Entry::Finish(Action::Command("home.alignLeft"))),
("HAC", Entry::Finish(Action::Command("home.alignCenter"))),
("HVS", Entry::Finish(Action::Dialog("pasteSpecial"))),
("ES", Entry::Finish(Action::Dialog("pasteSpecial"))), // Excel: Classic Paste Special dialog (legacy Edit route); A/F/T/V are dialog mnemonics
("HOI", Entry::Finish(Action::Command("home.autofitColumnWidth"))),
("OCA", Entry::Finish(Action::Command("home.autofitColumnWidth"))), // Legacy Format menu.
("WVG", Entry::Finish(Action::Command("view.gridlines"))),
("H0", Entry::Finish(Action::Command("home.increaseDecimal"))), // Excel: Increase Decimals; containment(Increase Decimal)
("H1", Entry::Finish(Action::Command("home.bold"))), // Excel: Bold
("H2", Entry::Finish(Action::Command("home.italic"))), // Excel: Italic
("H3", Entry::Finish(Action::Command("home.underline"))), // Excel: Excel: Underline; fires immediately (not a menu)
("H5", Entry::Finish(Action::Command("home.decreaseIndent"))), // Excel: Decrease Indent
("H6", Entry::Finish(Action::Command("home.increaseIndent"))), // Excel: Increase Indent
("H9", Entry::Finish(Action::Command("home.decreaseDecimal"))), // Excel: Decrease Decimals; containment(Decrease Decimal)
("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable
("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins)
("HB", Entry::Continue), // Excel: Borders Menu; group level:
("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders)
("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD
("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD
("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD
("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD
("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD)
("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD
("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders)
("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD
("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD
("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD
("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD
("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders)
("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD
("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD)
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting)
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands
("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane
("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace
("HFDS", Entry::Finish(Action::Dialog("goToSpecial"))), // Excel: Go To Special
("HFF", Entry::Finish(Action::Control("home.fontName"))), // Excel: Font Selection; opens the font dropdown
("HFG", Entry::Finish(Action::Command("home.increaseFontSize"))), // Excel: Increase Font Size
("HFI", Entry::Continue), // Excel: Fill; group level: containment(Fill Up)
("HFIA", Entry::Finish(Action::Pending("home.fillAcrossWorksheets"))), // pending; Excel: Fill Across Worksheets; Fill Across Worksheets; reserved until the command lands
("HFID", Entry::Finish(Action::Command("edit.fillDown"))), // Excel: Fill Down
("HFIF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill
("HFIJ", Entry::Finish(Action::Command("home.justify"))), // Excel: Fill Justify; containment(Justify)
("HFIL", Entry::Finish(Action::Command("edit.fillLeft"))), // Excel: Fill Left
("HFIR", Entry::Finish(Action::Command("edit.fillRight"))), // Excel: Fill Right
("HFIS", Entry::Finish(Action::Dialog("series"))), // Excel: Fill Series; containment(Series)
("HFIU", Entry::Finish(Action::Command("edit.fillUp"))), // Excel: Fill Up
("HFK", Entry::Finish(Action::Command("home.decreaseFontSize"))), // Excel: Decrease Font Size
("HFM", Entry::Finish(Action::Dialog("formatCells"))), // Excel: More Number Formats; More Number Formats opens Format Cells
("HFP", Entry::Finish(Action::Command("edit.formatPainter"))), // Excel: Format Painter
("HFQ", Entry::Finish(Action::Control("home.orientation"))), // Excel: Orientation Menu; group level: containment(Orientation)
("HFS", Entry::Finish(Action::Control("home.fontSize"))), // Excel: Font Size; opens the font-size dropdown
("HH", Entry::Finish(Action::Control("home.fillColor"))), // Excel: Fill Color; opens the fill-color palette
("HIC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert Columns; Insert Columns
("HIE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert Copied Cells; Insert Cells (copied cells TBD)
("HII", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert Cells
("HIR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert Rows; Insert Rows
("HIS", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert Sheet
("HJ", Entry::Continue), // Excel: Cell Styles; group level:
("HJN", Entry::Finish(Action::Command("home.newCellStyle"))), // Excel: New Cell Style
("HK", Entry::Finish(Action::Command("home.comma"))), // Excel: Comma Style
("HL", Entry::Continue), // Excel: Conditional Formatting; group level: containment(Conditional Formatting)
("HLC", Entry::Continue), // Excel: Clear Rules Menu; group level: containment(Clear Rules)
("HLCE", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Sheet; containment(Clear Rules)
("HLCS", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Cells; containment(Clear Rules)
("HLCT", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Table; containment(Clear Rules)
("HLD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Data Bars; Data Bars (submenu TBD)
("HLH", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Highlight Cells; Highlight Cells (submenu TBD)
("HLN", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: New Rule; New Rule (submenu TBD)
("HLR", Entry::Finish(Action::Command("home.manageRules"))), // Excel: Manage Rules; containment(Manage Rules)
("HLT", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Top/Bottom Rules; Top/Bottom (submenu TBD)
("HM", Entry::Continue), // Excel: Merge & Center Menu; group level: containment(Merge & Center)
("HMA", Entry::Finish(Action::Command("home.mergeAcross"))), // Excel: Merge Across
("HMC", Entry::Finish(Action::Command("home.mergeCenter"))), // Excel: Merge & Center
("HMM", Entry::Finish(Action::Command("home.mergeCells"))), // Excel: Merge Cells
("HMU", Entry::Finish(Action::Command("home.unmergeCells"))), // Excel: Unmerge Cells
("HN", Entry::Finish(Action::Control("home.numberFormat"))), // Excel: Number Format; opens the number-format dropdown
("HOA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: AutoFit Row Height
("HOD", Entry::Finish(Action::Dialog("defaultWidth"))), // Excel: Default Column Width; containment(Column Width)
("HOE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format Cells
("HOH", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Row Height
("HOL", Entry::Finish(Action::Command("home.lockCell"))), // Excel: Lock Cell; containment(Lock Cell)
("HOM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Move or Copy Sheet
("HOP", Entry::Finish(Action::Dialog("protectSheet"))), // Excel: Protect Sheet
("HOR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Rename Sheet
("HOT", Entry::Finish(Action::Command("sheet.tabColor"))), // Excel: Tab Color
("HOUC", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Hide Columns
("HOUH", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Unhide Sheet
("HOUL", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Unhide Columns
("HOUO", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Unhide Rows
("HOUR", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Hide Rows
("HOUS", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Hide Sheet
("HOW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Column Width
("HP", Entry::Finish(Action::Command("home.percent"))), // Excel: Excel: Percentage Style
("HSC", Entry::Finish(Action::Command("data.clearFilter"))), // Excel: Clear Filter
("HSF", Entry::Finish(Action::Command("data.filter"))), // Excel: Filter
("HSO", Entry::Finish(Action::Command("data.sortDescending"))), // Excel: Sort Z to A
("HSS", Entry::Finish(Action::Command("data.sortAscending"))), // Excel: Sort A to Z
("HSU", Entry::Finish(Action::Command("data.sort"))), // Excel: Custom Sort
("HT", Entry::Continue), // Excel: Format as Table; group level:
("HTN", Entry::Finish(Action::Command("table.style"))), // Excel: New Table Style
("HTP", Entry::Finish(Action::Command("table.style"))), // Excel: New Pivot Table Style
("HU", Entry::Continue), // Excel: AutoSum; group level:
("HUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Average; AutoSum: Average (variant TBD)
("HUC", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Count Numbers; AutoSum: Count (variant TBD)
("HUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Min; AutoSum: Min (variant TBD)
("HUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Max; AutoSum: Max (variant TBD)
("HV", Entry::Continue), // Excel: Paste; group level:
("HVP", Entry::Finish(Action::Command("edit.pasteSpecial"))), // Excel: Paste
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
("NC1", Entry::Finish(Action::Control("insert.chart.column"))), // Excel: Column Chart; Column chart (kind TBD)
("NC2", Entry::Finish(Action::Command("review.newComment"))), // Excel: New Comment
("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern)
("NCB", Entry::Finish(Action::Command("insert.checkbox"))), // Excel: Checkbox
("ND", Entry::Finish(Action::Control("insert.chart.scatter"))), // Excel: Scatter Chart; Scatter (kind TBD)
("NE", Entry::Finish(Action::Pending("insert.equation"))), // pending; Excel: Equations; Equations; reserved until the command lands
("NF", Entry::Continue), // Excel: Online Pictures; group level: containment(Picture)
("NFP", Entry::Continue), // Excel: From Picture Menu; group level: containment(Picture)
("NFPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture From File; containment(Picture)
("NH1", Entry::Finish(Action::Dialog("headerFooter"))), // Excel: Header & Footer
("NHF", Entry::Finish(Action::Command("pageLayout.headerFooter"))), // Excel: Header & Footer
("NHI", Entry::Finish(Action::Control("insert.chart.hierarchy"))), // Excel: Hierarchy Chart; Hierarchy (kind TBD)
("NI1", Entry::Finish(Action::Command("insert.chart"))), // Excel: Waterfall Chart; Waterfall (kind TBD)
("NI2", Entry::Finish(Action::Dialog("insertLink"))), // Excel: Link
("NJ", Entry::Finish(Action::Pending("insert.object"))), // pending; Excel: Object; insert.object missing; reserved until the command lands
("NM1T", Entry::Finish(Action::Command("insert.picture"))), // Excel: SmartArt Picture; containment(Picture)
("NM2", Entry::Finish(Action::Command("insert.chart"))), // Excel: Map Chart; Map (kind TBD)
("NN1", Entry::Finish(Action::Control("insert.chart.line"))), // Excel: Line Chart; Line chart (kind TBD)
("NNS", Entry::Finish(Action::Command("insert.icons"))), // Excel: Icons
("NP", Entry::Continue), // Excel: Picture; group level:
("NPB", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from File; containment(Picture)
("NPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place in Cell; Picture in cell (source TBD)
("NPI", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place in Cell Menu; Picture in cell (source TBD)
("NPO", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place over Cells Menu; Picture over cells (source TBD)
("NQ", Entry::Finish(Action::Control("insert.chart.pie"))), // Excel: Pie Chart; Pie (kind TBD)
("NR", Entry::Finish(Action::Command("insert.recommendedCharts"))), // Excel: Recommended Charts
("NSA", Entry::Finish(Action::Control("insert.chart.statistic"))), // Excel: Statistical Chart; Statistical chart (kind TBD)
("NSC", Entry::Finish(Action::Pending("insert.screenshot"))), // pending; Excel: Screenshot Menu; Screenshot; reserved until the command lands
("NSD", Entry::Finish(Action::Command("insert.chart"))), // Excel: Combo Chart; Combo (kind TBD)
("NSF", Entry::Finish(Action::Command("insert.slicer"))), // Excel: Slicer; insert.slicer missing (gap #8); reserved until the command lands
("NSH", Entry::Finish(Action::Command("insert.shape"))), // Excel: Shapes
("NSP", Entry::Finish(Action::Command("insert.recommendedPivotTables"))), // Excel: Recommended PivotTables
("NST", Entry::Finish(Action::Pending("insert.timeline"))), // pending; Excel: Timeline; insert.timeline missing (gap #8); reserved until the command lands
("NSZ", Entry::Finish(Action::Command("insert.pivotChart"))), // Excel: PivotChart; PivotChart; reserved until the command lands
("NT", Entry::Finish(Action::Command("insert.table"))), // Excel: Table
("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol)
("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern)
("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable
("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands
("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
("NZK", Entry::Continue), // Excel: Sparklines Menu; group level:
("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline
("NZKSO", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Column Sparkline
("NZKSW", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Win/Loss Sparkline
("NZTX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
("NZT", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Excel: Text Box (modern)
("PAE", Entry::Finish(Action::Command("arrange.sendBackward"))), // Excel: Send Backward
("PAF", Entry::Finish(Action::Command("arrange.bringForward"))), // Excel: Bring Forward
("PB", Entry::Continue), // Excel: Breaks; group level:
("PBI", Entry::Finish(Action::Command("pageLayout.breaks"))), // Excel: Insert Page Break; Insert Page Break
("PBR", Entry::Finish(Action::Command("pageLayout.breaks"))), // Excel: Remove Page Break; Remove Page Break
("PGB", Entry::Finish(Action::Command("pageLayout.background"))), // Excel: Background; Excel badge GB (Background in the Page Setup group)
("PI", Entry::Finish(Action::Command("pageLayout.printTitles"))), // Excel: Print Titles
("PM", Entry::Continue), // Excel: Margins; group level:
("PMA", Entry::Finish(Action::Command("pageLayout.margins"))), // Excel: Custom Margins; containment(Margins)
("PO", Entry::Finish(Action::Command("pageLayout.orientation"))), // Excel: Orientation
("PPG", Entry::Finish(Action::Command("pageLayout.printGridlines"))), // Excel: Print Gridlines
("PPH", Entry::Finish(Action::Command("pageLayout.printHeadings"))), // Excel: Print Headings
("PR", Entry::Continue), // Excel: Print Area; group level:
("PRS", Entry::Finish(Action::Command("pageLayout.printArea"))), // Excel: Set Print Area
("PSP", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: Page Setup
("PSZ", Entry::Finish(Action::Command("pageLayout.size"))), // Excel: Size
("PTC", Entry::Finish(Action::Command("pageLayout.themeColors"))), // Excel: Theme Colors; containment(Colors)
("PTE", Entry::Finish(Action::Command("pageLayout.themeEffects"))), // Excel: Theme Effects; containment(Effects)
("PTF", Entry::Finish(Action::Command("pageLayout.themeFonts"))), // Excel: Theme Fonts; containment(Fonts)
("PTH", Entry::Continue), // Excel: Themes; group level:
("PTHB", Entry::Finish(Action::Command("pageLayout.theme"))), // Excel: Browse Themes; containment(Themes)
("PVG", Entry::Finish(Action::Command("view.gridlines"))), // Excel: View Gridlines
("PVH", Entry::Finish(Action::Command("view.headings"))), // Excel: View Headings
("MA", Entry::Continue), // Excel: Remove Arrows Menu; group level: containment(Remove Arrows)
("MAA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Remove All Arrows; landed in the catalog; live since T7 probe
("MAP", Entry::Finish(Action::Pending("formulas.removePrecedentArrows"))), // pending; Excel: Remove Precedent Arrows; Remove Precedent Arrows; reserved until the command lands
("MB", Entry::Finish(Action::Command("formulas.calculateNow"))), // Excel: Calculate Now
("MC", Entry::Finish(Action::Command("formulas.createFromSelection"))), // Excel: Create From Selection
("MD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Trace Dependents
("ME", Entry::Finish(Action::Control("formulas.dateTime"))), // Excel: Date & Time Menu; group level: containment(Date & Time)
("MF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert Function
("MG", Entry::Finish(Action::Control("formulas.mathTrig"))), // Excel: Math & Trig Menu; group level: containment(Math & Trig)
("MH", Entry::Finish(Action::Command("formulas.showFormulas"))), // Excel: Show Formulas
("MI", Entry::Continue), // Excel: Financial Menu; group level: containment(Financial)
("MI1", Entry::Finish(Action::Control("formulas.financial"))), // Excel: Excel: Financial; first item of the dropdown
("MJ", Entry::Finish(Action::Command("formulas.calculateSheet"))), // Excel: Calculate Sheet
("MK", Entry::Continue), // Excel: Error Checking Menu; group level: containment(Error Checking)
("MKK", Entry::Finish(Action::Dialog("errorChecking"))), // Excel: Error Checking
("ML", Entry::Finish(Action::Control("formulas.logical"))), // Excel: Logical Menu; group level: containment(Logical)
("MM", Entry::Finish(Action::Dialog("defineName"))), // Excel: Define Name
("MN", Entry::Finish(Action::Dialog("nameManager"))), // Excel: Name Manager
("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference)
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
("MU", Entry::Continue), // Excel: AutoSum Menu; group level:
("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD)
("MUC", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Count Numbers; AutoSum: Count (variant TBD)
("MUF", Entry::Finish(Action::Command("formulas.moreFunctions"))), // Excel: More Functions (AutoSum); containment(More Functions)
("MUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Min; AutoSum: Min (variant TBD)
("MUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Max; AutoSum: Max (variant TBD)
("MUS", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Sum
("MV", Entry::Finish(Action::Dialog("evaluateFormula"))), // Excel: Evaluate Formula
("MW", Entry::Finish(Action::Command("formulas.watchWindow"))), // Excel: Watch Window
("MX", Entry::Continue), // Excel: Calculation Options Menu; group level: containment(Calculation Options)
("MXA", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Automatic Calculation; Calculation options: Automatic
("MXM", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Manual Calculation; Calculation options: Manual
("MXP", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Partial Calculation; Calculation options: Partial
("AB", Entry::Finish(Action::Dialog("subtotal"))), // Excel: Subtotal
("AC", Entry::Finish(Action::Command("data.clearFilter"))), // Excel: Clear Filter
("AD", Entry::Finish(Action::Command("data.consolidate"))), // Excel: Consolidate
("AE", Entry::Finish(Action::Dialog("textToColumns"))), // Excel: Text to Columns
("AF1", Entry::Continue), // Excel: Data from Picture Menu; group level: feature gap; reserved until the command lands
("AF1C", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from Clipboard (source TBD)
("AF1P", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from File (source TBD)
("AFA", Entry::Finish(Action::Pending("data.fromAccess"))), // pending; Excel: Get External Data: From Access; gap #12 Get & Transform; reserved until the command lands
("AFC", Entry::Finish(Action::Command("data.forecastSheet"))), // Excel: Forecast Sheet; IN FLIGHT: vangel76 PR #325; reserved until the command lands
("AFE", Entry::Finish(Action::Pending("data.analyzeData"))), // pending; Excel: Analyze Data; AI feature GAP; reserved until the command lands
("AFF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill
("AFO", Entry::Finish(Action::Pending("data.fromOtherSources"))), // pending; Excel: Get External Data: From Other Sources; gap #12; reserved until the command lands
("AFT", Entry::Finish(Action::Pending("data.fromText"))), // pending; Excel: Get External Data: From Text; gap #12; reserved until the command lands
("AFW", Entry::Finish(Action::Pending("data.fromWeb"))), // pending; Excel: Get External Data: From Web; reserved until the command lands
("AG", Entry::Continue), // Excel: Group Menu; group level: containment(Group)
("AGA", Entry::Finish(Action::Pending("data.autoOutline"))), // pending; Excel: Auto Outline; Auto Outline; reserved until the command lands
("AGG", Entry::Finish(Action::Command("data.group"))), // Excel: Group
("AH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Hide Detail
("AJ", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Show Detail
("AK", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Edit Links; Edit Links (Queries & Connections);
("AM", Entry::Finish(Action::Dialog("removeDuplicates"))), // Excel: Remove Duplicates
("AO", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Queries & Connections
("AP1", Entry::Finish(Action::Command("file.properties"))), // Excel: Properties
("APN", Entry::Continue), // Excel: Get Data (Power Query) Menu; group level: Power Query variants = GAP
("APND", Entry::Finish(Action::Pending("data.fromDatabase"))), // pending; Excel: From Database (Microsoft Query); gap #12; reserved until the command lands
("APNE", Entry::Finish(Action::Pending("data.powerQueryEditor"))), // pending; Excel: Launch Power Query Editor; gap #12; reserved until the command lands
("APNG", Entry::Finish(Action::Command("data.getData"))), // Excel: Get Data (Power Query); containment(Get Data)
("APNH", Entry::Finish(Action::Pending("data.fromHtml"))), // pending; Excel: From HTML; gap #12; reserved until the command lands
("APNL", Entry::Finish(Action::Pending("data.fromSqlServer"))), // pending; Excel: From SQL Server ODBC; gap #12; reserved until the command lands
("APNS", Entry::Finish(Action::Pending("data.dataSourceSettings"))), // pending; Excel: Data Source Settings; gap #12; reserved until the command lands
("APNT", Entry::Finish(Action::Pending("data.fromTextLegacy"))), // pending; Excel: From Text (Legacy); gap #12; reserved until the command lands
("APR", Entry::Finish(Action::Pending("data.recentSources"))), // pending; Excel: Recent Sources; gap #12; reserved until the command lands
("APS", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Show Queries
("APT", Entry::Finish(Action::Pending("data.fromTable"))), // pending; Excel: From Table (query); gap #12; reserved until the command lands
("AQ", Entry::Finish(Action::Command("data.advancedFilter"))), // Excel: Advanced Filter
("AR", Entry::Continue), // group: group: Queries & Connections
("ARA", Entry::Finish(Action::Command("data.refreshAll"))), // Excel: Refresh All
("ARC", Entry::Finish(Action::Pending("data.cancelRefresh"))), // pending; Excel: Cancel Refresh; gap #12; reserved until the command lands
("ARO", Entry::Finish(Action::Pending("data.connectionProperties"))), // pending; Excel: Connection Properties; gap #12; reserved until the command lands
("ARR", Entry::Finish(Action::Command("pivot.refresh"))), // Excel: Refresh
("ARS", Entry::Finish(Action::Pending("data.refreshStatus"))), // pending; Excel: Refresh Status; gap #12; reserved until the command lands
("ASA", Entry::Finish(Action::Command("data.sortAscending"))), // Excel: Sort A to Z
("ASD", Entry::Finish(Action::Command("data.sortDescending"))), // Excel: Sort Z to A
("ASS", Entry::Finish(Action::Dialog("sort"))), // Excel: Sort
("AT", Entry::Finish(Action::Command("data.filter"))), // Excel: Filter
("AU", Entry::Continue), // Excel: Ungroup Menu; group level: containment(Ungroup)
("AUC", Entry::Finish(Action::Pending("data.clearOutline"))), // pending; Excel: Clear Outline; feature gap; reserved until the command lands
("AUU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Ungroup
("AV", Entry::Continue), // Excel: Data Validation Menu; group level: containment(Data Validation)
("AVI", Entry::Finish(Action::Command("data.circleInvalid"))), // Excel: Circle Invalid Data
("AVR", Entry::Finish(Action::Pending("data.clearValidationCircles"))), // pending; Excel: Clear Validation Circles; reserved until the command lands
("AVV", Entry::Finish(Action::Command("data.validation"))), // Excel: Data Validation
("AW", Entry::Continue), // group: group: What-If (goalSeek/scenarioManager/dataTable)
("AWG", Entry::Finish(Action::Dialog("goalSeek"))), // Excel: Goal Seek
("AWS", Entry::Finish(Action::Command("data.scenarioManager"))), // Excel: Scenario Manager
("AWT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data Table
("AX", Entry::Finish(Action::Pending("data.existingConnections"))), // pending; Excel: Existing Connections; gap #12; reserved until the command lands
("AY", Entry::Finish(Action::Command("data.reapply"))), // Excel: Reapply Filter; containment(Reapply)
("RA1", Entry::Finish(Action::Dialog("accessibility"))), // Excel: Check Accessibility
("RA2", Entry::Finish(Action::Command("review.showComments"))), // Excel: Show All Comments; Show All Comments; reserved until the command lands
("RC", Entry::Finish(Action::Dialog("comment"))), // Excel: New Comment
("RD", Entry::Finish(Action::Command("review.deleteComment"))), // Excel: Delete Comment
("RG", Entry::Finish(Action::Pending("review.showChanges"))), // pending; Excel: Track Changes; reserved until the command lands
("RL", Entry::Finish(Action::Pending("review.translate"))), // pending; Excel: Translate; feature gap; reserved until the command lands
("RN", Entry::Finish(Action::Command("review.nextComment"))), // Excel: Next Comment
("RO", Entry::Finish(Action::Command("review.protectWorkbook"))), // Excel: Protect and Share Workbook
("RPS", Entry::Finish(Action::Dialog("protectSheet"))), // Excel: Protect Sheet
("RPW", Entry::Finish(Action::Command("review.protectWorkbook"))), // Excel: Protect Workbook
("RRS", Entry::Finish(Action::Pending("review.smartLookup"))), // pending; Excel: Smart Lookup; feature gap; reserved until the command lands
("RS", Entry::Finish(Action::Dialog("spelling"))), // Excel: Spelling
("RTN", Entry::Finish(Action::Command("review.newNote"))), // Excel: New Note
("RV", Entry::Finish(Action::Command("review.previousComment"))), // Excel: Previous Comment
("WA", Entry::Finish(Action::Command("view.arrangeAll"))), // Excel: Arrange All
("WC", Entry::Finish(Action::Command("view.customViews"))), // Excel: Custom Views
("WF", Entry::Continue), // Excel: Freeze Panes Menu; group level:
("WFC", Entry::Finish(Action::Command("view.freezeFirstColumn"))), // Excel: Freeze First Column
("WFF", Entry::Finish(Action::Command("view.freezePanes"))), // Excel: Freeze Panes
("WFR", Entry::Finish(Action::Command("view.freezeTopRow"))), // Excel: Freeze Top Row
("WG", Entry::Finish(Action::Command("view.zoomToSelection"))), // Excel: Zoom To Selection
("WH", Entry::Finish(Action::Command("view.hideWindow"))), // Excel: Hide Window
("WI", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: Page Break Preview
("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param)
("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal)
("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros)
("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu)
("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros)
("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window
("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View
("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog
("WR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler
("WS", Entry::Finish(Action::Command("view.split"))), // Excel: Split
("WT", Entry::Finish(Action::Pending("view.resetWindowPosition"))), // pending; Excel: Reset Window Position; reserved until the command lands
("WU", Entry::Finish(Action::Command("view.unhideWindow"))), // Excel: Unhide Window
("WVC", Entry::Finish(Action::Pending("view.focusCell"))), // pending; Excel: Focus Cell Menu; Focus Cell; reserved until the command lands
("WVF", Entry::Finish(Action::Control("view.formulaBar"))), // Excel: Toggle Formula Bar; toggles the formula bar
("WVH", Entry::Finish(Action::Command("view.headings"))), // Excel: Toggle Headings; containment(Headings)
("WVN", Entry::Finish(Action::Pending("view.navigation"))), // pending; Excel: Navigation; Navigation pane; reserved until the command lands
("WVR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler
("WW", Entry::Finish(Action::Command("view.switchWindows"))), // Excel: Switch Windows
("JAM", Entry::Finish(Action::Command("chart.formatSelection"))), // Excel: Chart format: Format Selection pane; containment(Format Selection)
("JCA", Entry::Finish(Action::Command("chart.addElement"))), // Excel: Chart: Add Chart Element; containment(Add Chart Element)
("JCC", Entry::Finish(Action::Command("chart.changeType"))), // Excel: Chart: Change Chart Type; containment(Change Chart Type)
("JTS", Entry::Finish(Action::Command("table.style"))), // Excel: Table: table style
("JTV", Entry::Finish(Action::Command("table.summarizePivot"))), // Excel: Table: Summarize with PivotTable; containment(Summarize with PivotTable)
("FA", Entry::Finish(Action::Command("file.saveAs"))), // Excel: Save / Save As page; fires immediately in Excel; deeper aliases dropped as unreachable
("FC", Entry::Finish(Action::Command("file.close"))), // Excel: Close workbook
("FEA", Entry::Finish(Action::Command("file.exportPdf"))), // Excel: Export: create PDF/XPS; Export: create PDF/XPS
("FEC", Entry::Finish(Action::Command("file.saveAs"))), // Excel: Export: change file type; Export: change file type
("FI", Entry::Finish(Action::Command("file.properties"))), // Excel: Info page; fires immediately in Excel; deeper aliases dropped as unreachable
("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page
("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable
("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print)
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
];
File diff suppressed because it is too large Load Diff
+103 -33
View File
@@ -26,6 +26,7 @@ pub mod outline;
pub mod panes;
pub mod paper_view;
pub mod pivot_pane;
pub mod printing;
pub mod ribbon;
pub mod selection_glide;
pub mod tabs;
@@ -61,6 +62,10 @@ pub struct UiState {
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
pub animations: bool,
pub recent: Vec<String>,
/// One-shot keytip request for a ribbon control: set when a keytip action resolves to
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
#[serde(skip)]
pub keytip_invoke: Option<&'static str>,
/// Interface language. English by default (so tests and headless renders never depend on the
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
@@ -104,6 +109,7 @@ impl Default for UiState {
status_bar: true,
animations: true,
recent: vec![],
keytip_invoke: None,
language: i18n::Language::En,
interface_scale: 100,
}
@@ -178,6 +184,10 @@ pub struct Notice {
/// Host services the platform shell provides (file pickers, opening URLs).
#[derive(Default)]
pub struct Services {
/// Desktop: discover configured printers on a worker.
pub printers: Option<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Native picture picker (PNG/JPEG), separate from workbook opening.
@@ -229,6 +239,8 @@ pub struct SheetApp {
/// Dismissible info bar under the ribbon (open-time notices).
pub notice: Option<Notice>,
pub services: Services,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
/// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
@@ -275,6 +287,8 @@ impl SheetApp {
toast: None,
notice: None,
services,
printing: Default::default(),
print_feedback_seen: None,
synthetic: Default::default(),
control_rx: None,
grid: grid::GridState {
@@ -423,8 +437,12 @@ impl SheetApp {
}
pub fn run_or_alert(&mut self, id: &str, params: Json) {
// Printing from the UI also hands the PDF to the host's print flow (web).
// A menu/shortcut opens the settings first; the Print page's button submits explicitly.
if id == "file.print" {
if self.services.print_native.is_some() {
self.open_backstage(backstage::Page::Print);
return;
}
return self.print_now(params);
}
if let Err(e) = self.run(id, params) {
@@ -441,29 +459,39 @@ impl SheetApp {
}
}
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web
/// build) hands the bytes straight to the browser's print flow; errors show the usual
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
/// (which still produces the PDF — just without a UI to act on the result yet).
/// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the
/// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible
/// on the Print page. The engine command still returns PDF bytes without submitting.
pub fn print_now(&mut self, params: Json) {
match self.run("file.print", params) {
Ok(r) => {
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
return;
};
if let Some(print) = &self.services.print {
print(&bytes);
}
}
Err(e) => self.alert(e),
if let Err(error) = self.submit_print(params) {
self.alert(error);
}
}
fn submit_print(&mut self, params: Json) -> Result<Json, String> {
if self.services.print_native.is_some() && !self.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
let pdf = self.run("file.print", params)?;
let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?;
let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?;
let len = bytes.len();
if let Some(submit) = &self.services.print_native {
self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?;
self.print_feedback_seen = None;
} else if let Some(print) = &self.services.print {
print(&bytes);
} else {
return Err("Printing is unavailable here; export a PDF instead.".into());
}
Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()}))
}
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
let r = match id {
"ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
"ui.printSubmit" => self.submit_print(p.clone()),
"ui.ribbonTab" => {
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
Ok(Json::Null)
@@ -644,17 +672,27 @@ impl SheetApp {
pub fn open_path(&mut self, path: &str) {
match self.run("file.open", json!({"path": path})) {
Ok(r) => {
self.ui.recent.retain(|p| p != path);
self.ui.recent.insert(0, path.to_string());
self.ui.recent.truncate(20);
self.show_open_warnings(&r);
}
Ok(r) => self.after_open(Some(path), &r),
// A password-protected workbook: ask for the password; the dialog opens it.
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})),
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
}
/// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent
/// list (for a file on disk) and the open warnings notice. Shared by File › Open, the
/// password dialog and the control channel.
pub fn after_open(&mut self, path: Option<&str>, r: &Json) {
self.backstage = None;
if let Some(path) = path {
self.ui.recent.retain(|p| p != path);
self.ui.recent.insert(0, path.to_string());
self.ui.recent.truncate(20);
}
self.show_open_warnings(r);
}
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
/// Kept deliberately generic: any open-time warning can reach the user this way.
@@ -839,8 +877,31 @@ impl SheetApp {
self.grid.ensure_visible = true;
}
/// Terminal feedback also reaches the normal window when printing starts through control.
pub fn poll_printing(&mut self, ctx: &egui::Context) {
self.printing.poll(ctx);
if self.printing.message == self.print_feedback_seen {
return;
}
self.print_feedback_seen = self.printing.message.clone();
let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) =
self.printing.message.clone()
else {
return;
};
let text = printing::feedback(&message, |label| self.ui.language.tr(label).to_owned());
if matches!(message, printing::Feedback::Submitted(_)) {
self.toast = Some((text, now_ms()));
} else {
self.alert(text);
}
}
/// Per-frame logic (control channel, screenshots). Call before `ui`.
pub fn logic(&mut self, ctx: &egui::Context) {
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
self.ui.keytip_invoke = None;
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
self.fonts_ready = false;
self.fonts_set = false;
@@ -917,12 +978,16 @@ impl SheetApp {
.unwrap_or_default();
for (name, bytes) in arrived {
let b64 = gridcraft_engine::io::base64_encode(&bytes);
match self.run("file.open", json!({"name": name, "base64": b64})) {
match self.run("file.open", json!({"name": &name, "base64": &b64})) {
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
Ok(r) => {
self.backstage = None;
self.show_open_warnings(&r);
}
Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => {
self.backstage = None;
self.open_dialog("openPassword", json!({"name": name, "base64": b64}));
}
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
@@ -941,6 +1006,7 @@ impl SheetApp {
/// Lays out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
self.poll_printing(&ctx);
if !self.fonts_ready || self.fonts_rebind {
ctx.request_repaint();
return;
@@ -1031,21 +1097,25 @@ impl SheetApp {
// Leave pointer cancellation to the popup so clicking a menu item still works.
egui::Popup::close_all(ctx);
}
// The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when
// the prefix changes, not every frame (that would reset a tab the user picked meanwhile).
let after = self.keytips.prefix();
if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
self.ui.ribbon_collapsed = false;
}
for action in actions {
match action {
keytips::Action::Home => {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
keytips::Action::Tab(tab) => {
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
self.ui.ribbon_collapsed = false;
}
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
// Reserved sequence: fires silently while its command is missing. When the
// command lands in the catalog it goes live with no table edit.
keytips::Action::Pending(id) => {
if gridcraft_engine::find_command(id).is_some() {
self.run_or_alert(id, json!({}));
}
}
// Route to the ribbon control that owns this keytip: it opens its dropdown or
// runs its click handler with the right params (the engine command alone would
// be missing them).
keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id),
}
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Localized print-page wording around the shared, backend-independent controls.
use craft_ui::printing::Labels;
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit};
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
let labels = Labels {
printer: &translate("Printer"),
select_printer: &translate("Select a printer"),
refresh: &translate("Refresh printers"),
copies: &translate("Copies"),
duplex: &translate("Two-sided printing"),
printer_default: &translate("Printer default"),
one_sided: &translate("One-sided"),
long_edge: &translate("Two-sided: long edge"),
short_edge: &translate("Two-sided: short edge"),
grayscale: &translate("Grayscale"),
};
state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels);
if let Some(message) = &state.message {
ui.label(feedback(message, translate));
}
}
/// Read print settings/status, or validate an explicit update before changing anything.
/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog.
pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result<serde_json::Value, String> {
use serde_json::{Value, json};
let object = params.as_object().ok_or("print settings must be an object")?;
if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) {
return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into());
}
if state.busy() && !object.is_empty() {
return Err("wait for the active print submission before changing settings".into());
}
let mut next = state.settings.clone();
if let Some(value) = object.get("printer") {
let printer = value.as_str().ok_or("printer must be a name from the discovered list")?;
if !state.printers.iter().any(|available| available.name == printer) {
return Err("printer must be a name from the discovered list".into());
}
next.printer = printer.to_owned();
}
if let Some(value) = object.get("copies") {
let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?;
next.copies = copies as u32;
}
if let Some(value) = object.get("duplex") {
next.duplex = match value.as_str() {
Some("printerDefault") => None,
Some("off") => Some(Duplex::Off),
Some("longEdge") => Some(Duplex::LongEdge),
Some("shortEdge") => Some(Duplex::ShortEdge),
None if value.is_null() => None,
_ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()),
};
}
if let Some(value) = object.get("grayscale") {
next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?;
}
let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false);
if refresh {
let discover = discover.ok_or("native printer discovery is unavailable here")?;
if !state.refresh(discover) {
return Err("printer discovery or submission is already active".into());
}
}
state.settings = next;
let (status, detail) = match state.message.as_ref() {
None => ("idle", ""),
Some(Feedback::FindingPrinters) => ("findingPrinters", ""),
Some(Feedback::NoPrinters) => ("noPrinters", ""),
Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""),
Some(Feedback::Submitting) => ("submitting", ""),
Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()),
Some(Feedback::Failed(error)) => ("failed", error.as_str()),
Some(Feedback::WorkerStopped) => ("workerStopped", ""),
};
Ok(json!({
"printer": state.settings.printer, "copies": state.settings.copies,
"duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" },
"grayscale": state.settings.grayscale,
"printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::<Vec<Value>>(),
"busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail,
}))
}
pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String {
match message {
Feedback::FindingPrinters => translate("Finding printers…"),
Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."),
Feedback::DiscoveryFailed => translate("Printer discovery failed."),
Feedback::Submitting => translate("Submitting print job…"),
Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."),
Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt),
Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error),
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
}
}
File diff suppressed because it is too large Load Diff
+89 -11
View File
@@ -145,9 +145,83 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f3
}
/// Our colour palette (theme row + tints + standard colours), returns a picked hex colour,
/// `Some("none")` for No Fill/Automatic.
/// `Some("none")` for No Fill/Automatic. Arrow keys move a highlight through the swatches and
/// Enter applies; the key events are stripped so the grid behind the popup does not also react.
pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option<String> {
let mut picked = None;
const ROWS: usize = 7; // 6 tint rows + 1 standard row
const COLS: usize = 10;
let pal_id = ui.id().with("__color_palette");
let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None);
let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false);
ui.input(|i| {
dl = i.key_pressed(egui::Key::ArrowLeft);
dr = i.key_pressed(egui::Key::ArrowRight);
du = i.key_pressed(egui::Key::ArrowUp);
dd = i.key_pressed(egui::Key::ArrowDown);
ent = i.key_pressed(egui::Key::Enter);
});
if dl || dr || du || dd {
cur = match cur {
None => Some((
0,
if dr {
1
} else if dl {
COLS - 1
} else {
0
},
)),
Some((r, c)) => {
let r2 = if du {
r.saturating_sub(1)
} else if dd {
(r + 1).min(ROWS - 1)
} else {
r
};
let c2 = if dl {
(c + COLS - 1) % COLS
} else if dr {
(c + 1) % COLS
} else {
c
};
Some((r2, c2))
}
};
}
if dl || dr || du || dd || ent {
ui.ctx().input_mut(|i| {
i.events.retain(|e| {
!matches!(
e,
egui::Event::Key {
key: egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown | egui::Key::Enter,
pressed: true,
..
}
)
});
});
}
let theme_hex = |base: u32, tint: f64| -> String {
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
let c = gridcraft_engine::model::style::apply_tint(rgb, tint);
let (r0, r1, r2) = (c[0], c[1], c[2]);
format!("#{r0:02X}{r1:02X}{r2:02X}")
};
let std_hex = ["#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050", "#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0"];
if ent && let Some((r, c)) = cur {
picked = Some(if r < ROWS - 1 {
let base = theme_colors.get(c).copied().unwrap_or(0);
theme_hex(base, [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5][r])
} else {
std_hex[c].to_string()
});
ui.data_mut(|d| d.remove_temp::<Option<(usize, usize)>>(pal_id));
}
if ui.button(none_label).clicked() {
picked = Some("none".to_string());
}
@@ -155,20 +229,20 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
let order = [0usize, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let tints = [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5];
egui::Grid::new(ui.id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| {
for tint in tints {
for &i in &order {
for (row, tint) in tints.iter().enumerate() {
for (col, &i) in order.iter().enumerate() {
let base = theme_colors.get(i).copied().unwrap_or(0);
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
let c = gridcraft_engine::model::style::apply_tint(rgb, tint);
let col = Color32::from_rgb(c[0], c[1], c[2]);
let c = gridcraft_engine::model::style::apply_tint(rgb, *tint);
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, col);
ui.painter().rect_filled(r, 1.0, col32);
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
if resp.hovered() {
if resp.hovered() || cur == Some((row, col)) {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
}
if resp.clicked() {
picked = Some(format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]));
picked = Some(theme_hex(base, *tint));
}
}
ui.end_row();
@@ -177,18 +251,22 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
ui.label(egui::RichText::new("Standard Colors").small());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for hex in ["#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050", "#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0"] {
let col = gridcraft_engine::model::Color::from_hex(hex)
for (col, hex) in std_hex.iter().enumerate() {
let c = gridcraft_engine::model::Color::from_hex(hex)
.and_then(|c| c.resolve(&Default::default()))
.map(theme::color32)
.unwrap_or(Color32::BLACK);
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, col);
ui.painter().rect_filled(r, 1.0, c);
if resp.hovered() || cur == Some((ROWS - 1, col)) {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
}
if resp.clicked() {
picked = Some(hex.to_string());
}
}
});
ui.data_mut(|d| d.insert_temp(pal_id, cur));
picked
}
+18
View File
@@ -142,3 +142,21 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() {
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper");
}
#[test]
fn argument_hint_shows_the_function_description() {
// #363: inside DATEDIF( the hint lists the units the third argument takes.
let ctx = Context::default();
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
frames(&ctx, &mut app);
app.begin_edit(Some("=DATEDIF(A1,B1,".into()), false);
frames(&ctx, &mut app);
let output = frame(&ctx, &mut app);
let texts: Vec<&egui::epaint::TextShape> =
output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }).collect();
assert!(texts.iter().any(|t| t.galley.text() == "DATEDIF("), "signature painted");
let desc = texts.iter().find(|t| t.galley.text().contains("YM or YD")).expect("description with the units painted");
assert!(desc.galley.rect.width() <= 400.0, "description wraps instead of widening the hint: {}", desc.galley.rect.width());
}
+39
View File
@@ -152,3 +152,42 @@ fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line
assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line");
}
}
#[test]
fn table_filter_autofit_works_through_command_and_header_double_click() {
for zoom in [50, 100, 200] {
for double_click in [false, true] {
let mut h = harness("A:A");
h.state_mut().session.execute("range.setValues", json!({"range":"A1", "values":[["Quarterly Revenue"], ["1"], ["2"]]})).unwrap();
h.state_mut().session.execute("insert.table", json!({"range":"A1:A3", "header":true})).unwrap();
h.state_mut().session.execute("home.rowHeight", json!({"rows":"1:1", "height":20})).unwrap();
h.state_mut().session.execute("view.zoom", json!({"percent":zoom})).unwrap();
h.run_steps(3);
h.state_mut().session.active_mut().unwrap().undo.clear();
h.state_mut().session.journal.clear();
if double_click {
let (pos, _) = edge(&h, false, 0);
button(&mut h, pos, true);
button(&mut h, pos, false);
h.step();
button(&mut h, pos, true);
button(&mut h, pos, false);
} else {
h.state_mut().run("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
}
h.run_steps(2);
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let cell = gridcraft_engine::core::CellRef::new(0, 0);
let rect = geo.cell_rect(sheet, cell);
let button = gridcraft_ui_egui::grid::filter_button_rect(&geo, sheet, cell);
let style = app.session.active().unwrap().wb.styles.get(sheet.style_id(cell));
let text_width = gridcraft_engine::cmd::format::approx_text_width("Quarterly Revenue", style.font.size, style.font.bold) * geo.z;
assert!(rect.left() + text_width + 2.5 * geo.z < button.left(), "heading overlaps filter: zoom={zoom}, double_click={double_click}");
assert_eq!(app.session.active().unwrap().undo.len(), 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(sizes(&h, false)[0], 100.0);
}
}
}
+223
View File
@@ -174,6 +174,7 @@ fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() {
#[test]
fn legacy_paste_special_keeps_formulas_when_requested() {
let mut h = harness(copied_row());
// The legacy Edit route opens the dialog directly; F is a dialog mnemonic.
sequence(&mut h, &[Key::E, Key::S], false);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
letter(&mut h, Key::F, false);
@@ -295,6 +296,80 @@ fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
assert_eq!(value(&h, "A1"), Value::Empty);
}
#[test]
fn tab_letters_switch_to_their_ribbon_tabs() {
let mut h = harness(blank());
for (k, tab) in [(Key::N, "Insert"), (Key::P, "Page Layout"), (Key::M, "Formulas"), (Key::A, "Data"), (Key::R, "Review"), (Key::W, "View")] {
key(&mut h, Key::F10); // arm
letter(&mut h, k, false);
assert_eq!(h.state().ui.ribbon_tab, tab, "letter {k:?}");
assert!(h.state().editor.is_none(), "tab letters must not begin a cell edit");
key(&mut h, Key::F10); // disarm
assert_eq!(h.state().keytips.prefix(), None);
}
// A terminal tab unit with no deeper sequences finishes and clears.
key(&mut h, Key::F10); // re-arm
letter(&mut h, Key::J, false);
assert_eq!(h.state().keytips.prefix(), Some("J"), "J waits for its multi-letter unit");
letter(&mut h, Key::I, false);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly");
}
#[test]
fn view_gridlines_sequence_runs_end_to_end() {
let mut h = harness(blank());
let gridlines = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().show_gridlines;
assert!(gridlines(&h), "gridlines start visible");
key(&mut h, Key::F10);
letter(&mut h, Key::W, false);
// W has deeper sequences: it stays as an active level so View badges show.
assert_eq!(h.state().ui.ribbon_tab, "View");
assert_eq!(h.state().keytips.prefix(), Some("W"));
letter(&mut h, Key::V, false);
assert_eq!(h.state().keytips.prefix(), Some("WV"), "implicit group level waits for the last key");
letter(&mut h, Key::G, false);
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
assert!(!gridlines(&h), "WVG toggles gridlines off");
key(&mut h, Key::F10);
for k in [Key::W, Key::V, Key::G] {
letter(&mut h, k, false);
}
assert!(gridlines(&h), "WVG toggles gridlines back on");
}
#[test]
fn two_letter_unit_waits_after_first_letter_then_fires() {
let mut h = harness(blank());
key(&mut h, Key::F10);
letter(&mut h, Key::J, false);
// "J" is not a complete sequence, but "JI" starts with it: the matcher waits.
assert_eq!(h.state().keytips.prefix(), Some("J"));
assert_eq!(h.state().ui.ribbon_tab, "Home");
letter(&mut h, Key::I, false);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().editor.is_none(), "unit letters must not begin a cell edit");
}
#[test]
fn two_letter_unit_fires_when_pressed_in_one_frame() {
let mut h = harness(blank());
key(&mut h, Key::F10);
events(
&mut h,
[
key_event(Key::J, true, Modifiers::NONE),
Event::Text("j".into()),
key_event(Key::I, true, Modifiers::NONE),
Event::Text("i".into()),
key_event(Key::J, false, Modifiers::NONE),
key_event(Key::I, false, Modifiers::NONE),
],
);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
}
#[test]
fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() {
let mut h = harness(blank());
@@ -327,3 +402,151 @@ fn vertical_alignment_keytips_run_their_commands() {
assert_eq!(h.state().session.journal.iter().filter(|(j, _)| j == id).count(), 1, "HA{} runs {id}", letter.name());
}
}
#[test]
fn pending_sequences_are_silent_no_ops() {
let mut h = harness(blank());
let journal = h.state().session.journal.len();
sequence(&mut h, &[Key::N, Key::W], false); // NW -> insert.wordArt (missing)
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
assert!(h.state().message.is_none(), "no message box for a pending sequence");
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
assert_eq!(h.state().session.journal.len(), journal, "no journal entry for a pending sequence");
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
}
#[test]
fn pending_sequences_go_live_when_the_command_lands() {
// The command lands in the catalog (here: a test-registered stand-in).
// The sequence must run it with no table edit.
gridcraft_engine::register_test_command("insert.equation", |s, _| {
s.execute("cell.set", json!({"cell": "Z9", "input": "live"}))?;
Ok(json!({}))
});
let mut h = harness(blank());
sequence(&mut h, &[Key::N, Key::E], false); // NE -> insert.equation
assert_eq!(value(&h, "Z9"), Value::from("live"), "the pending sequence ran the landed command");
assert_eq!(
h.state().session.journal.iter().filter(|(id, _)| id == "insert.equation").count(),
1,
"the landed command is journaled like any live command"
);
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn control_keytips_open_dropdowns_and_run_handlers() {
// HFF routes to the font control: its dropdown opens (popup items queryable).
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFF must not raise an error box");
// "Calibri" appears twice: once on the combo button (the current font) and
// once inside the opened dropdown — a single node would mean it stayed closed.
assert!(h.query_all_by_label("Calibri").count() >= 2, "HFF must open the font dropdown");
// WVF toggles the formula bar: pure UI state, no engine command exists.
let mut h = harness(blank());
assert!(h.state().session.prefs.formula_bar);
sequence(&mut h, &[Key::W, Key::V, Key::F], false);
assert!(!h.state().session.prefs.formula_bar, "WVF must toggle the formula bar off");
// WJ runs the 100% handler with its percent param ({} would raise "missing percent").
let mut h = harness(blank());
sequence(&mut h, &[Key::W, Key::J], false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "WJ must run view.zoom with the percent param");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn editable_combos_filter_by_typing_and_move_by_arrows() {
// Open the font dropdown (HFF), type to filter, then Enter to apply.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3); // let the filter field gain focus
events(&mut h, [Event::Text("arial".into())]);
h.run_steps(3);
// "arial" matches Arial only — Georgia must be filtered out of the list.
assert_eq!(h.query_all_by_label("Georgia").count(), 0, "typing must narrow the list");
assert!(h.query_all_by_label("Arial").count() >= 1, "the match must stay visible");
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "applying must not raise an error");
// The popup closed and the combo button now shows the applied font.
assert_eq!(h.query_all_by_label("Arial").count(), 1, "Enter must apply Arial and close the popup");
assert_eq!(h.query_all_by_label("Georgia").count(), 0);
// Arrows alone move through the full list: Calibri -> Cambria -> Candara -> Consolas.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
for _ in 0..3 {
key(&mut h, Key::ArrowDown);
}
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none());
assert_eq!(h.query_all_by_label("Consolas").count(), 1, "Down x3 from Calibri must land on Consolas");
// Size combo: type a custom size not in the list, Enter applies it.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::S], false);
h.run_steps(3);
events(&mut h, [Event::Text("13.5".into())]);
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "custom sizes must apply");
assert_eq!(h.query_all_by_label("13.5").count(), 1, "the button must show the custom size");
// Closing without applying (Escape) and reopening must reset the filter to the full list.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
events(&mut h, [Event::Text("arial".into())]);
h.run_steps(3);
key(&mut h, Key::Escape);
h.run_steps(3);
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(4);
assert!(h.query_all_by_label("Georgia").count() >= 1, "reopening must show the full, unfiltered list");
// Arrows after typing move the highlight, not the text cursor: type a filter,
// then Down twice and Enter must apply the second match's neighbour logic.
key(&mut h, Key::Escape);
h.run_steps(3);
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
events(&mut h, [Event::Text("candara".into())]);
h.run_steps(3);
key(&mut h, Key::Enter);
h.run_steps(3);
assert_eq!(h.query_all_by_label("Candara").count(), 1, "filtered Enter must apply the filtered row");
}
#[test]
fn color_palette_navigates_with_arrows_and_enter_applies() {
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::H], false); // H,H opens the fill palette
h.run_steps(3);
key(&mut h, Key::ArrowDown); // highlight starts at theme row 0
key(&mut h, Key::ArrowRight); // -> row 0, col 1 = Craft theme dk1 (#000000)
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "arrow+Enter must not error");
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1, "Enter applies the highlighted swatch");
assert_eq!(h.state().grid.last_fill, "#000000", "the applied fill is the highlighted colour");
assert_eq!(h.query_all_by_label("Theme Colors").count(), 0, "the palette closes after applying");
}
#[test]
fn every_sequence_terminates_without_panicking() {
use gridcraft_ui_egui::keytips::keytip_table::SEQUENCES;
for (seq, _entry) in SEQUENCES {
let mut h = harness(blank());
key(&mut h, Key::F10); // arm
for ch in seq.chars() {
let k = Key::from_name(&ch.to_string()).expect("sequence letters map to keys");
letter(&mut h, k, false);
}
// A finish/tab/dialog/wait state is all fine; the point is nothing panics.
}
assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran");
}
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
//! Native print onboarding preserves engine PDF generation and submits the selected settings.
use gridcraft_engine::Session;
use gridcraft_ui_egui::printing::{Duplex, Feedback, Printer};
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
use std::{cell::RefCell, rc::Rc};
#[test]
fn native_print_uses_selected_settings_and_reports_spool_errors() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings| {
observed.borrow_mut().push((bytes, settings));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Err("printer is offline".into())).unwrap();
receiver
}));
app.run_or_alert("file.print", json!({}));
assert!(app.backstage.is_some(), "a shortcut opens settings without submitting");
let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap();
assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting");
assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool");
app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false });
let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap();
assert_eq!(configured["ready"], true);
assert_eq!(configured["printers"][0]["name"], "Chosen queue");
app.run("ui.printSubmit", json!({"sheets":"active"})).unwrap();
assert!(app.printing.busy());
let jobs = jobs.borrow();
assert_eq!(jobs.len(), 1);
assert!(jobs[0].0.starts_with(b"%PDF"));
assert_eq!(jobs[0].1.printer, "Chosen queue");
assert_eq!(jobs[0].1.copies, 3);
assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge));
assert!(jobs[0].1.grayscale);
app.poll_printing(&egui::Context::default());
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
assert!(app.message.as_ref().unwrap().1.contains("printer is offline"));
}
#[test]
fn print_settings_are_visible_in_a_narrow_window() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap();
app.services.printers = Some(Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
receiver
}));
app.services.print_native = Some(Box::new(|_, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
app.run("ui.backstage", json!({"page":"print"})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.run_steps(5);
assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled());
assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled());
harness.get_by_label("Grayscale").click();
harness.run_steps(2);
assert!(harness.state().printing.settings.grayscale);
assert!(harness.state().printing.ready());
}
#[test]
fn invalid_print_settings_are_rejected_without_partial_changes() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
let before = app.run("ui.printSettings", json!({})).unwrap();
for params in [
json!({"printer":"Missing"}),
json!({"copies":0}),
json!({"copies":1000}),
json!({"copies":1.5}),
json!({"copies":-1}),
json!({"copies":"3"}),
json!({"duplex":"maybe"}),
json!({"grayscale":"yes"}),
json!({"refresh":"yes"}),
json!({"copies":3,"duplex":"invalid"}),
json!([]),
] {
assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}");
assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}");
}
}
#[test]
fn repeated_fast_jobs_each_surface_an_identical_receipt() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
app.services.print_native = Some(Box::new(|_, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("same receipt".into())).unwrap();
receiver
}));
for _ in 0..2 {
app.toast = None;
app.run("ui.printSubmit", json!({})).unwrap();
// Completion may be consumed by the shared widget before the app's logic pass.
app.printing.poll(&egui::Context::default());
app.poll_printing(&egui::Context::default());
assert!(app.toast.as_ref().unwrap().0.contains("same receipt"));
app.toast = None;
app.poll_printing(&egui::Context::default());
assert!(app.toast.is_none());
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Opening a password-protected workbook asks for its password (#11).
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::{Session, core::CellRef, core::Value};
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
use serde_json::json;
const PW: &str = "synthetic-pw-8842";
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, services),
);
h.run_steps(4);
h
}
/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `PW`.
fn encrypted_bytes() -> Vec<u8> {
let mut s = Session::new();
s.new_workbook();
s.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
let xlsx = gridcraft_engine::io::save_bytes(&s.active().unwrap().wb, "book.xlsx").unwrap();
gridcraft_xlsx::testing::encrypt_workbook(&xlsx, PW)
}
/// `encrypted_bytes()` written to `Secret.xlsx` in a fresh temp dir named after `tag`.
fn encrypted_file(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-ui-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Secret.xlsx");
std::fs::write(&path, encrypted_bytes()).unwrap();
path
}
fn dialog(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<String> {
h.state().dialog.as_ref().map(|d| d.name().to_string())
}
fn a1(h: &egui_kittest::Harness<'static, SheetApp>) -> Value {
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap())
}
#[test]
fn opening_an_encrypted_file_asks_for_its_password() {
let path = encrypted_file("desktop");
let p = path.to_string_lossy().to_string();
let mut h = harness(Services::default());
h.state_mut().open_path(&p);
h.run_steps(3);
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
assert!(h.state().message.is_none(), "no error box: the dialog asks instead");
assert!(h.query_by_role(Role::PasswordInput).is_some(), "the password is masked");
// A wrong password says so, clears the field, and keeps the dialog and its file.
h.get_by_role(Role::PasswordInput).click();
h.run_steps(2);
h.input_mut().events.push(egui::Event::Text("wrong-guess".into()));
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(3);
let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default();
assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}");
assert!(!msg.contains("wrong-guess"), "the password is never quoted: {msg}");
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!("")));
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("path")), Some(&json!(p)));
h.state_mut().message = None;
// The right one opens the workbook: no save path, in Recent, with the notice.
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
h.get_by_label(" OK ").click();
h.run_steps(3);
assert_eq!(dialog(&h), None);
assert_eq!(a1(&h), Value::Number(42.0));
assert_eq!(h.state().session.active().unwrap().path, None);
assert_eq!(h.state().ui.recent.first(), Some(&p));
assert!(h.state().notice.as_ref().is_some_and(|n| n.text.contains("password-protected")));
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
#[test]
fn cancel_leaves_nothing_open() {
let path = encrypted_file("cancel");
let mut h = harness(Services::default());
let before = h.state().session.documents().len();
h.state_mut().open_path(&path.to_string_lossy());
h.run_steps(3);
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
h.get_by_label(" Cancel ").click();
h.run_steps(3);
assert_eq!(dialog(&h), None);
assert_eq!(h.state().session.documents().len(), before);
assert!(h.state().message.is_none());
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
#[test]
fn a_file_picked_on_the_web_asks_for_its_password_too() {
let inbox: Inbox = Default::default();
let mut h = harness(Services { inbox: Some(inbox.clone()), ..Default::default() });
inbox.lock().unwrap().push(("Secret.xlsx".into(), encrypted_bytes()));
h.run_steps(3);
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
assert!(h.state().dialog.as_ref().is_some_and(|d| d.values.contains_key("base64")));
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
h.get_by_label(" OK ").click();
h.run_steps(3);
assert_eq!(dialog(&h), None);
assert_eq!(a1(&h), Value::Number(42.0));
assert_eq!(h.state().session.active().unwrap().path, None);
}
#[test]
fn an_untouched_password_field_is_incorrect_not_required() {
let path = encrypted_file("untouched");
let mut h = harness(Services::default());
h.state_mut().open_path(&path.to_string_lossy());
h.run_steps(3);
h.get_by_label(" OK ").click();
h.run_steps(3);
let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default();
assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}");
assert_eq!(dialog(&h).as_deref(), Some("openPassword"));
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
#[test]
fn the_backstage_closes_once_the_password_opens_the_workbook() {
let path = encrypted_file("backstage");
let mut h = harness(Services::default());
h.state_mut().backstage = Some(gridcraft_ui_egui::backstage::Backstage::new(gridcraft_ui_egui::backstage::Page::Open));
h.state_mut().open_path(&path.to_string_lossy());
h.run_steps(3);
h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW));
h.get_by_label(" OK ").click();
h.run_steps(3);
assert_eq!(a1(&h), Value::Number(42.0));
assert!(h.state().backstage.is_none(), "the opened workbook shows, not the Backstage");
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
#[test]
fn app_open_over_the_control_channel_never_opens_a_dialog() {
use gridcraft_ui_egui::control::{Outcome, handle};
let path = encrypted_file("control");
let p = path.to_string_lossy().to_string();
let mut h = harness(Services::default());
let ctx = h.ctx.clone();
let reply = |o: Outcome| match o {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!(null),
};
let r = reply(handle(h.state_mut(), &ctx, "app.open", &json!({"path": p})));
assert_eq!(r["ok"], json!(false), "{r}");
assert!(r["error"].as_str().unwrap_or_default().contains(gridcraft_engine::io::PASSWORD_REQUIRED), "{r}");
assert_eq!(dialog(&h), None, "programmatic calls never open dialogs");
let r = reply(handle(h.state_mut(), &ctx, "app.open", &json!({"path": p, "password": PW})));
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(r["result"]["title"], json!("Secret.xlsx"));
assert_eq!(a1(&h), Value::Number(42.0));
assert_eq!(h.state().ui.recent.first(), Some(&p));
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
+8
View File
@@ -8,6 +8,11 @@ description = "GridCraft file formats: XLSX (SpreadsheetML) and CSV import/expor
[lints]
workspace = true
[features]
# Builds password-encrypted workbooks in code for other crates' tests (a dev-dependency feature;
# never enabled in a shipped build).
test-support = []
[dependencies]
gridcraft-core = { workspace = true }
gridcraft-formula = { workspace = true }
@@ -15,3 +20,6 @@ gridcraft-model = { workspace = true }
thiserror = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
aes = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
+2 -1
View File
@@ -83,7 +83,8 @@ impl Scan<'_> {
let child = self.expr(x, sheet, array_context, depth + 1);
match op {
UnOp::At => Shape { array: false, affected: child.affected },
UnOp::Spill => Shape { array: true, affected: child.affected },
// A trim reference is a reference operator, like `:`.
UnOp::Spill | UnOp::Trim(_) => Shape { array: true, affected: child.affected },
_ => Shape { array: child.array, affected: child.affected || (child.array && !array_context) },
}
}
+117 -1
View File
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
use gridcraft_core::Value;
use gridcraft_formula::{Expr, SheetSel};
use gridcraft_model::{Chart, ChartKind, Color, LegendPos, Series, Workbook};
use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook};
use crate::drawing::{dml_color, srgb};
use crate::xml::{El, esc, num};
@@ -107,9 +107,61 @@ fn read_series(s: &El) -> Series {
secondary: false,
kind: None,
smooth: s.child("smooth").and_then(|e| e.attr("val")).and_then(|v| v.parse::<u8>().ok()).is_some_and(|v| v != 0),
trendlines: s.kids("trendline").filter_map(read_trendline).collect(),
}
}
const DASHES: [(LineDash, &str); 11] = [
(LineDash::Solid, "solid"),
(LineDash::Dot, "dot"),
(LineDash::Dash, "dash"),
(LineDash::LgDash, "lgDash"),
(LineDash::DashDot, "dashDot"),
(LineDash::LgDashDot, "lgDashDot"),
(LineDash::LgDashDotDot, "lgDashDotDot"),
(LineDash::SysDash, "sysDash"),
(LineDash::SysDot, "sysDot"),
(LineDash::SysDashDot, "sysDashDot"),
(LineDash::SysDashDotDot, "sysDashDotDot"),
];
const EMU_PER_PT: f64 = 12_700.0;
/// A `CT_Boolean` child (`val` defaults to true).
fn bool_val(e: &El, tag: &str) -> bool {
e.child(tag).is_some_and(|c| c.flag("val", true))
}
fn f64_val(e: &El, tag: &str) -> Option<f64> {
e.child(tag).and_then(|c| c.attr_f64("val"))
}
/// `<c:trendline>` (ECMA-376 Part 1, 21.2.2.211). Unknown types are dropped.
fn read_trendline(t: &El) -> Option<Trendline> {
let kind = match t.child_val("trendlineType").unwrap_or("linear") {
"linear" => TrendlineKind::Linear,
"exp" => TrendlineKind::Exponential,
"log" => TrendlineKind::Logarithmic,
"power" => TrendlineKind::Power,
"poly" => TrendlineKind::Polynomial(t.child_val("order").and_then(|v| v.trim().parse::<u8>().ok()).unwrap_or(2).clamp(2, 6)),
"movingAvg" => TrendlineKind::MovingAverage(t.child_val("period").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(2).clamp(2, 255)),
_ => return None,
};
let ln = t.path(&["spPr", "ln"]);
Some(Trendline {
kind,
name: t.child("name").map(|n| n.text.clone()),
forward: f64_val(t, "forward").unwrap_or(0.0).max(0.0),
backward: f64_val(t, "backward").unwrap_or(0.0).max(0.0),
intercept: f64_val(t, "intercept"),
display_equation: bool_val(t, "dispEq"),
display_r_squared: bool_val(t, "dispRSqr"),
color: ln.and_then(|l| l.child("solidFill")).and_then(dml_color),
width: ln.and_then(|l| l.attr_f64("w")).map(|w| (w / EMU_PER_PT) as f32).filter(|w| w.is_finite() && *w >= 0.0),
dash: ln.and_then(|l| l.child("prstDash")).and_then(|d| d.attr("val")).and_then(|v| DASHES.iter().find(|(_, n)| *n == v).map(|(d, _)| *d)),
})
}
fn rich_title(t: &El) -> Option<String> {
let tx = t.child("tx")?;
if let Some(rich) = tx.child("rich") {
@@ -343,6 +395,13 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
G::Bubble => x.push_str("<c:invertIfNegative val=\"0\"/>"),
_ => {}
}
// Trendlines sit between dLbls and the data in CT_LineSer, CT_BarSer, CT_AreaSer,
// CT_ScatterSer and CT_BubbleSer; pie and radar series have none.
if !matches!(g, G::Pie | G::Doughnut | G::Radar) {
for t in &s.trendlines {
x.push_str(&trendline_xml(t, wb));
}
}
match g {
G::Scatter { .. } | G::Bubble => {
if let Some(c) = &s.categories {
@@ -370,6 +429,63 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
x
}
/// `<c:trendline>` with children in schema order: name, spPr, trendlineType, order, period,
/// forward, backward, intercept, dispRSqr, dispEq, trendlineLbl.
fn trendline_xml(t: &Trendline, wb: &Workbook) -> String {
let mut x = String::from("<c:trendline>");
if let Some(n) = &t.name {
let _ = write!(x, "<c:name>{}</c:name>", esc(n));
}
let color = t.color.as_ref().and_then(|c| srgb(c, &wb.theme));
if color.is_some() || t.width.is_some() || t.dash.is_some() {
x.push_str("<c:spPr><a:ln");
if let Some(w) = t.width.filter(|w| w.is_finite()) {
// ST_LineWidth: 0..=20116800 EMU.
let _ = write!(x, " w=\"{}\"", (f64::from(w) * EMU_PER_PT).round().clamp(0.0, 20_116_800.0) as i64);
}
x.push_str(" cap=\"rnd\">");
if let Some(c) = &color {
let _ = write!(x, "<a:solidFill>{c}</a:solidFill>");
}
if let Some(d) = t.dash.and_then(|d| DASHES.iter().find(|(k, _)| *k == d)) {
let _ = write!(x, "<a:prstDash val=\"{}\"/>", d.1);
}
x.push_str("</a:ln></c:spPr>");
}
let ty = match t.kind {
TrendlineKind::Linear => "linear",
TrendlineKind::Exponential => "exp",
TrendlineKind::Logarithmic => "log",
TrendlineKind::Polynomial(_) => "poly",
TrendlineKind::Power => "power",
TrendlineKind::MovingAverage(_) => "movingAvg",
};
let _ = write!(x, "<c:trendlineType val=\"{ty}\"/>");
match t.kind {
TrendlineKind::Polynomial(o) => {
let _ = write!(x, "<c:order val=\"{}\"/>", o.clamp(2, 6));
}
TrendlineKind::MovingAverage(p) => {
let _ = write!(x, "<c:period val=\"{}\"/>", p.clamp(2, 255));
}
_ => {}
}
for (tag, v) in [("forward", t.forward), ("backward", t.backward)] {
if v.is_finite() && v > 0.0 {
let _ = write!(x, "<c:{tag} val=\"{}\"/>", num(v));
}
}
if let Some(i) = t.intercept.filter(|i| i.is_finite()) {
let _ = write!(x, "<c:intercept val=\"{}\"/>", num(i));
}
let _ = write!(x, "<c:dispRSqr val=\"{}\"/><c:dispEq val=\"{}\"/>", t.display_r_squared as u8, t.display_equation as u8);
if t.display_r_squared || t.display_equation {
x.push_str("<c:trendlineLbl><c:numFmt formatCode=\"General\" sourceLinked=\"0\"/></c:trendlineLbl>");
}
x.push_str("</c:trendline>");
x
}
fn dlbls(show: bool) -> String {
format!(
"<c:dLbls><c:showLegendKey val=\"0\"/><c:showVal val=\"{}\"/><c:showCatName val=\"0\"/><c:showSerName val=\"0\"/><c:showPercent val=\"0\"/><c:showBubbleSize val=\"0\"/></c:dLbls>",
+469
View File
@@ -0,0 +1,469 @@
//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with
//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells
//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption
//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and
//! certificate-only encryption are reported as unsupported.
//!
//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is
//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped.
use aes::cipher::{BlockCipherDecrypt, KeyInit};
use sha2::Digest;
use crate::cfb::Compound;
use crate::xml::El;
/// Spin counts above this are refused (the cap sheet-protection hashes use too).
const MAX_SPIN_COUNT: u32 = 10_000_000;
/// Standard encryption always iterates this many times.
const STANDARD_SPIN_COUNT: u32 = 50_000;
/// Agile packages are encrypted in segments of this many bytes, each with its own IV.
pub(crate) const SEGMENT: usize = 4096;
/// AES block size.
const BLOCK: usize = 16;
/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password.
pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79];
pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E];
pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6];
/// The password key encryptor's URI (the other kind is certificate-based).
pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1).
pub(crate) const F_CRYPTOAPI: u32 = 0x04;
const F_EXTERNAL: u32 = 0x10;
pub(crate) const F_AES: u32 = 0x20;
/// Why an encrypted workbook couldn't be decrypted.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum DecryptError {
#[error("the password is incorrect")]
WrongPassword,
/// The scheme, e.g. "RC4" or "Extensible", to complete "uses {0} encryption".
#[error("{0} encryption isn't supported")]
Unsupported(String),
#[error("the encrypted file is damaged ({0})")]
Malformed(String),
}
fn malformed(m: impl Into<String>) -> DecryptError {
DecryptError::Malformed(m.into())
}
/// The plain OOXML package (a zip) inside an encrypted workbook.
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
let (info, package) = streams(bytes)?;
decrypt_streams(&info, &package, password)
}
/// Whether the right password could decrypt this encrypted workbook, checked without one. An
/// error names a scheme no password opens (RC4, rights management…) or damage, so callers can
/// say so instead of asking for a password first.
pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> {
let (info, _) = streams(bytes)?;
match scheme(&info)? {
Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()),
Scheme::Standard(info) => standard_header(info).map(|_| ()),
}
}
/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the
/// package but describes its encryption elsewhere, with no `EncryptionInfo`.
fn streams(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), DecryptError> {
let cf = Compound::open(bytes).map_err(malformed)?;
let info = cf.root_stream("EncryptionInfo").map_err(malformed)?;
let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?;
let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?;
Ok((info, package))
}
/// Decrypts from the two streams' contents.
pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
match scheme(info)? {
Scheme::Agile(xml) => agile(xml, package, password),
Scheme::Standard(info) => standard(info, package, password),
}
}
/// The two schemes read, with the part of `EncryptionInfo` each one parses.
enum Scheme<'a> {
/// The XML descriptor after the version and reserved field.
Agile(&'a [u8]),
/// Everything after the version.
Standard(&'a [u8]),
}
fn scheme(info: &[u8]) -> Result<Scheme<'_>, DecryptError> {
let short = || malformed("truncated EncryptionInfo");
let major = u16_at(info, 0).ok_or_else(short)?;
let minor = u16_at(info, 2).ok_or_else(short)?;
match (major, minor) {
(4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)),
(2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)),
(3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())),
_ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))),
}
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
let s = b.get(at..at.checked_add(2)?)?;
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
}
/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger
/// than the ciphertext is damage, so it never drives an allocation.
fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> {
let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?;
let data = package.get(8..).unwrap_or_default();
let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX);
if size > data.len() {
return Err(malformed("the package is shorter than its stated size"));
}
Ok((size, data))
}
// ---- hashing -------------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Hash {
Sha1,
Sha256,
Sha384,
Sha512,
}
impl Hash {
fn from_name(name: &str) -> Result<Hash, DecryptError> {
Ok(match name {
"SHA1" | "SHA-1" => Hash::Sha1,
"SHA256" | "SHA-256" => Hash::Sha256,
"SHA384" | "SHA-384" => Hash::Sha384,
"SHA512" | "SHA-512" => Hash::Sha512,
other => return Err(DecryptError::Unsupported(format!("{other} hash"))),
})
}
/// The digest of the concatenated `parts`.
pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
let mut d = D::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
match self {
Hash::Sha1 => run::<sha1::Sha1>(parts),
Hash::Sha256 => run::<sha2::Sha256>(parts),
Hash::Sha384 => run::<sha2::Sha384>(parts),
Hash::Sha512 => run::<sha2::Sha512>(parts),
}
}
}
/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds.
pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
let mut h = hash.digest(&[salt, &pw]);
for i in 0..spin {
h = hash.digest(&[&i.to_le_bytes(), &h]);
}
h
}
/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs.
pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
let mut k = hash.digest(&[a, b]);
k.resize(len, 0x36);
k
}
/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the
/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`.
pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec<u8> {
let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT);
let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]);
let expand = |pad: u8| {
let mut buf = [pad; 64];
for (b, x) in buf.iter_mut().zip(&h) {
*b ^= x;
}
Hash::Sha1.digest(&[&buf])
};
let mut key = expand(0x36);
key.extend(expand(0x5C));
key.truncate(key_len);
key
}
// ---- AES -----------------------------------------------------------------------------------
enum Aes {
A128(aes::Aes128),
A192(aes::Aes192),
A256(aes::Aes256),
}
impl Aes {
fn new(key: &[u8]) -> Result<Aes, DecryptError> {
let bad = |_| malformed("bad AES key");
Ok(match key.len() {
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?),
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?),
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?),
n => return Err(malformed(format!("a {n}-byte AES key"))),
})
}
fn decrypt_block(&self, block: &mut [u8; BLOCK]) {
let b = aes::Block::cast_from_core_mut(block);
match self {
Aes::A128(c) => c.decrypt_block(b),
Aes::A192(c) => c.decrypt_block(b),
Aes::A256(c) => c.decrypt_block(b),
}
}
/// ECB: each block on its own. `data` must be whole blocks.
fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> {
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
if !rest.is_empty() {
return Err(malformed("ciphertext isn't whole AES blocks"));
}
blocks.iter_mut().for_each(|b| self.decrypt_block(b));
Ok(())
}
/// CBC: each block XORed with the previous ciphertext block (the IV for the first).
fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> {
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?;
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
if !rest.is_empty() {
return Err(malformed("ciphertext isn't whole AES blocks"));
}
for b in blocks {
let cipher = *b;
self.decrypt_block(b);
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
prev = cipher;
}
Ok(())
}
}
// ---- Standard encryption -------------------------------------------------------------------
/// What a Standard `EncryptionInfo` holds: the key size and the password verifier.
struct StandardHeader<'a> {
key_len: usize,
salt: &'a [u8],
verifier: &'a [u8],
verifier_hash: &'a [u8],
}
/// `info` starts after the version: flags, header size, header, verifier.
fn standard_header(info: &[u8]) -> Result<StandardHeader<'_>, DecryptError> {
let short = || malformed("truncated Standard encryption header");
let flags = u32_at(info, 0).ok_or_else(short)?;
if flags & F_EXTERNAL != 0 {
return Err(DecryptError::Unsupported("external".into()));
}
if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 {
return Err(DecryptError::Unsupported("RC4".into()));
}
let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?;
let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?;
let alg_id = u32_at(header, 8).ok_or_else(short)?;
let alg_hash = u32_at(header, 12).ok_or_else(short)?;
let key_bits = u32_at(header, 16).ok_or_else(short)?;
let key_len = match (alg_id, key_bits) {
(0x660E | 0, 128) => 16,
(0x660F, 192) => 24,
(0x6610, 256) => 32,
(0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))),
_ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))),
};
if alg_hash != 0x8004 && alg_hash != 0 {
return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}")));
}
// Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash.
let v = info.get(8 + header_size..).ok_or_else(short)?;
if u32_at(v, 0) != Some(16) {
return Err(malformed("a Standard salt that isn't 16 bytes"));
}
let salt = v.get(4..20).ok_or_else(short)?;
let verifier = v.get(20..36).ok_or_else(short)?;
let verifier_hash = v.get(40..72).ok_or_else(short)?;
Ok(StandardHeader { key_len, salt, verifier, verifier_hash })
}
fn standard(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
let h = standard_header(info)?;
let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec());
let aes = Aes::new(&standard_key(password, h.salt, h.key_len))?;
aes.decrypt_ecb(&mut verifier)?;
aes.decrypt_ecb(&mut verifier_hash)?;
if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) {
return Err(DecryptError::WrongPassword);
}
let (size, data) = package_parts(package)?;
let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec();
aes.decrypt_ecb(&mut out)?;
out.truncate(size);
Ok(out)
}
// ---- Agile encryption ----------------------------------------------------------------------
/// The cipher parameters shared by `keyData` and a password `encryptedKey` element.
pub(crate) struct Params {
pub(crate) hash: Hash,
pub(crate) salt: Vec<u8>,
pub(crate) key_len: usize,
}
impl Params {
fn read(e: &El) -> Result<Params, DecryptError> {
let cipher = e.attr("cipherAlgorithm").unwrap_or("AES");
if cipher != "AES" {
return Err(DecryptError::Unsupported(cipher.into()));
}
let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC");
if chaining != "ChainingModeCBC" {
return Err(DecryptError::Unsupported(format!("AES {chaining}")));
}
if e.attr_u32("blockSize") != Some(16) {
return Err(malformed("an AES block size that isn't 16"));
}
let key_len = match e.attr_u32("keyBits") {
Some(128) => 16,
Some(192) => 24,
Some(256) => 32,
other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))),
};
let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?;
let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?;
if salt.is_empty() || salt.len() > 64 {
return Err(malformed("a salt that isn't 1 to 64 bytes"));
}
Ok(Params { hash, salt, key_len })
}
/// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block.
pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec<u8> {
match block_key {
Some(k) => derive(self.hash, &self.salt, k, BLOCK),
None => {
let mut iv = self.salt.clone();
iv.resize(BLOCK, 0x36);
iv
}
}
}
}
/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its
/// three encrypted values (verifier input, verifier hash, package key).
struct AgileDescriptor {
key_data: Params,
key: Params,
spin: u32,
verifier_input: Vec<u8>,
verifier_hash: Vec<u8>,
key_value: Vec<u8>,
}
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?;
let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?;
let encryptor = root
.child("keyEncryptors")
.and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR)))
.ok_or_else(|| DecryptError::Unsupported("certificate".into()))?
.child("encryptedKey")
.ok_or_else(|| malformed("no encryptedKey"))?;
let key = Params::read(encryptor)?;
let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?;
if spin > MAX_SPIN_COUNT {
return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed")));
}
let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?);
Ok(AgileDescriptor {
key_data,
key,
spin,
verifier_input: field("encryptedVerifierHashInput")?,
verifier_hash: field("encryptedVerifierHashValue")?,
key_value: field("encryptedKeyValue")?,
})
}
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?;
let h = iterated_hash(key.hash, password, &key.salt, spin);
let iv = key.iv(None);
let field = |data: &[u8], block_key: &[u8]| -> Result<Vec<u8>, DecryptError> {
let mut data = data.to_vec();
Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?;
Ok(data)
};
let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?;
let expected = field(&verifier_hash, &BLOCK_VERIFIER_HASH)?;
let input = input.get(..key.salt.len()).unwrap_or(&input);
let actual = key.hash.digest(&[input]);
if expected.get(..actual.len()) != Some(actual.as_slice()) {
return Err(DecryptError::WrongPassword);
}
let secret = field(&key_value, &BLOCK_KEY)?;
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
let (size, data) = package_parts(package)?;
let mut out = Vec::with_capacity(size);
for (i, segment) in data.chunks(SEGMENT).enumerate() {
if out.len() >= size {
break;
}
let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?;
let start = out.len();
out.extend_from_slice(segment);
let block = out.get_mut(start..).unwrap_or_default();
aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?;
}
out.truncate(size);
Ok(out)
}
// ---- base64 --------------------------------------------------------------------------------
/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value.
fn base64(s: &str) -> Result<Vec<u8>, DecryptError> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let (mut buf, mut bits) = (0u32, 0u32);
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return Err(malformed("invalid base64")),
};
buf = (buf << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
buf &= (1 << bits) - 1;
}
}
Ok(out)
}
#[cfg(test)]
mod tests;
+157
View File
@@ -0,0 +1,157 @@
//! Decryption tests. The expected keys and IVs below were computed independently with
//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted
//! files are built in code by `crate::testing`. No Excel-produced file is used.
use super::*;
use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header};
const PASSWORD: &str = "Pass1234";
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn salt() -> Vec<u8> {
(0u8..16).collect()
}
#[test]
fn aes_decrypts_the_fips_197_vectors() {
let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap();
Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b);
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap();
Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b);
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
}
#[test]
fn agile_keys_match_msoffcrypto() {
let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000);
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6"));
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03"));
let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000);
assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b"));
let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 };
assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b"));
}
#[test]
fn agile_pads_short_hashes_with_0x36() {
// SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow.
let k = derive(Hash::Sha1, b"a", b"b", 32);
assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]);
assert_eq!(k[20..], [0x36; 12]);
}
#[test]
fn standard_keys_match_msoffcrypto() {
assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4"));
assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f"));
}
#[test]
fn base64_decodes_and_rejects_junk() {
assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt());
assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]);
assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_))));
}
/// A payload that isn't a whole number of segments or blocks.
fn payload(len: usize) -> Vec<u8> {
(0..len).map(|i| (i * 7 + i / 251) as u8).collect()
}
#[test]
fn agile_round_trips() {
let plain = payload(10_000);
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
}
#[test]
fn standard_round_trips() {
let plain = payload(10_000);
let (info, package) = encrypt_standard(&plain, PASSWORD);
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
}
#[test]
fn non_ascii_passwords_round_trip() {
// UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP.
let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}";
let (info, package) = encrypt_agile(&payload(100), pw, 1000);
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword));
let (info, package) = encrypt_standard(&payload(100), pw);
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
}
#[test]
fn a_wrong_password_is_reported_as_such() {
let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000);
assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword));
assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword));
let (info, package) = encrypt_standard(&payload(100), PASSWORD);
assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword));
}
#[test]
fn unsupported_schemes_are_named() {
let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) {
Err(DecryptError::Unsupported(s)) => s,
other => panic!("expected Unsupported, got {other:?}"),
};
let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD);
rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES
assert_eq!(unsupported(&rc4), "RC4");
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
assert_eq!(unsupported(&with_agile_header(&des)), "3DES");
let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate");
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
}
#[test]
fn damaged_files_are_malformed_not_panics() {
let is_malformed = |r: Result<Vec<u8>, DecryptError>| matches!(r, Err(DecryptError::Malformed(_)));
let plain = payload(5000);
for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] {
// Every truncation of either stream.
for cut in (0..info.len()).step_by(7) {
let _ = decrypt_streams(&info[..cut], &package, PASSWORD);
}
for cut in (0..package.len()).step_by(97) {
let _ = decrypt_streams(&info, &package[..cut], PASSWORD);
}
// A stated size past the ciphertext never drives an allocation.
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD)));
// Ciphertext that isn't whole AES blocks.
let mut ragged = package.clone();
ragged.truncate(ragged.len() - 3);
let len = (ragged.len() - 8) as u64;
ragged[..8].copy_from_slice(&len.to_le_bytes());
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
}
let xml = agile_info(PASSWORD, 1000);
let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD));
assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\"")));
assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*")));
assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\"")));
assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\"")));
assert!(bad(xml.replace("<keyData", "<other")));
assert!(bad("<encryption".into()));
}
#[test]
fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
// "Pässwörd€" plus an emoji outside the BMP, a surrogate pair in UTF-16.
let h = iterated_hash(Hash::Sha512, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", &salt(), 1000);
assert_eq!(
h,
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
);
}
+7 -16
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use gridcraft_core::parse::parse_input;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value, number_to_text};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value};
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
use crate::{CsvOptions, IoError};
@@ -139,6 +139,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
}
let mut wb = Workbook::new();
wb.date_system = opts.date_system;
wb.csv_delimiter = u8::try_from(delim).ok();
let mut sheet = Sheet::new("Sheet1");
let mut styles = std::mem::take(&mut wb.styles);
let mut fmt_cache: Vec<(&'static str, StyleId)> = vec![];
@@ -183,20 +184,10 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
Ok(wb)
}
fn field_text(v: &Value) -> String {
match v {
Value::Empty => String::new(),
Value::Number(n) => number_to_text(*n),
Value::Text(t) => t.to_string(),
Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(),
Value::Error(e) => e.as_str().into(),
Value::Array(a) => a.get(0, 0).map(field_text).unwrap_or_default(),
}
}
/// Writes the sheet's used range as CSV (raw values, CRLF line ends, UTF-8 without BOM).
pub fn write_csv(sheet: &Sheet, wb: &Workbook, delimiter: u8) -> Vec<u8> {
let _ = wb;
/// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives
/// each cell's field text: the caller decides how values look (Save As CSV writes them as
/// displayed, number formats applied); quoting is done here.
pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec<u8> {
let delim = if delimiter == 0 { ',' } else { delimiter as char };
let mut out = String::new();
let Some(r) = sheet.used_range() else { return Vec::new() };
@@ -205,7 +196,7 @@ pub fn write_csv(sheet: &Sheet, wb: &Workbook, delimiter: u8) -> Vec<u8> {
if col > r.start.col {
out.push(delim);
}
let t = field_text(&sheet.value(CellRef::new(row, col)));
let t = cell_text(CellRef::new(row, col));
if t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r') {
out.push('"');
out.push_str(&t.replace('"', "\"\""));
+27 -1
View File
@@ -76,10 +76,19 @@ fn expr_to_file(e: Expr) -> String {
Some(n) => Expr::Call(n, args),
None => Expr::Call(name, args),
},
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`.
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`, and a spill
// reference that became `#REF!` (its cell was deleted) as plain `#REF!`.
Expr::Unary(gridcraft_formula::UnOp::Spill, r) if matches!(*r, Expr::Error(_)) => *r,
Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]),
// Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`.
Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]),
// Nor trim-reference operators: written as the equivalent `TRIMRANGE(range,n,n)`, which
// every Excel with trim references reads. (Excel's own file spelling of `A1:.E10` is
// not publicly documented; the `_xlfn._TRO_*` call forms are accepted on read.)
Expr::Unary(gridcraft_formula::UnOp::Trim(mode), x) => {
let code = Expr::Number(f64::from(mode.code()));
Expr::Call("_xlfn.TRIMRANGE".into(), vec![*x, code.clone(), code])
}
other => other,
});
gridcraft_formula::print(&e)
@@ -115,6 +124,11 @@ mod tests {
assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)");
assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)");
assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#");
// Found by the command-sequence fuzzer: deleting the anchor of `H5#` left `=#REF!` in
// the workbook but `ANCHORARRAY(#REF!)` in the file, which read back differently.
let deleted = Formula::from_expr(Expr::Unary(gridcraft_formula::UnOp::Spill, Box::new(Expr::Error(gridcraft_core::CellError::Ref))));
assert_eq!(deleted.text, "#REF!");
assert_eq!(to_file(&deleted), "#REF!");
}
#[test]
@@ -126,6 +140,18 @@ mod tests {
assert_eq!(from_file("SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"), "SUM(@A1:A3*B1:B3)");
}
#[test]
fn trim_references() {
assert_eq!(to_file(&Formula::new("SUM(A1:.E10)+SUM(Data!A.:A)")), "SUM(_xlfn.TRIMRANGE(A1:E10,2,2))+SUM(_xlfn.TRIMRANGE(Data!A:A,1,1))");
assert_eq!(text_to_file("=ROWS(1.:.5)"), "ROWS(_xlfn.TRIMRANGE(1:5,3,3))");
assert_eq!(from_file("SUM(_xlfn._TRO_TRAILING(A1:E10))+_xlfn._TRO_ALL($A:$B)"), "SUM(A1:.E10)+$A.:.$B");
}
#[test]
fn unbracketed_this_row_column() {
assert_eq!(from_file("Sales[[#This Row],Units]*[[#This Row],Price]"), "Sales[@Units]*[@Price]");
}
#[test]
fn prefixes() {
assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)");
+57 -4
View File
@@ -1,5 +1,5 @@
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import
//! and legacy Excel 97-2003 `.xls` (BIFF8) import.
//! and legacy Excel 97-2003 `.xls` (BIFF8) import, and password-encrypted workbooks (Agile and Standard encryption).
//!
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
@@ -15,6 +15,7 @@ mod array_formula;
mod cfb;
mod chart;
mod chartex;
mod crypto;
mod csv;
mod drawing;
mod feature_bags;
@@ -29,6 +30,8 @@ mod sheet_read;
mod sheet_write;
mod styles;
mod tables;
#[cfg(any(test, feature = "test-support"))]
pub mod testing;
mod theme;
mod write;
mod xls;
@@ -41,6 +44,7 @@ mod tests;
use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
pub use csv::{read_csv, write_csv};
pub use ods::read_ods;
pub use xls::read_xls;
@@ -74,14 +78,63 @@ impl ReadReport {
}
}
/// Reads an `.xlsx` / `.xlsm` package.
/// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the
/// package relationships, so every main-part content type is accepted).
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
read::read_xlsx(bytes)
}
/// Writes a workbook as an `.xlsx` package.
/// Which member of the SpreadsheetML family a package is saved as. Only the content type of the
/// workbook part differs (ECMA-376 Part 1 Workbook part; MS-OI29500 adds the macro-enabled types).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum XlsxKind {
/// `.xlsx`
#[default]
Workbook,
/// `.xlsm`
MacroWorkbook,
/// `.xltx`
Template,
/// `.xltm`
MacroTemplate,
}
impl XlsxKind {
/// The kind a file name's extension asks for; anything else is a plain workbook.
pub fn from_path(path: &str) -> XlsxKind {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
match ext.as_str() {
"xlsm" => XlsxKind::MacroWorkbook,
"xltx" => XlsxKind::Template,
"xltm" => XlsxKind::MacroTemplate,
_ => XlsxKind::Workbook,
}
}
/// Content type of `/xl/workbook.xml`.
pub fn main_content_type(self) -> &'static str {
match self {
XlsxKind::Workbook => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
XlsxKind::MacroWorkbook => "application/vnd.ms-excel.sheet.macroEnabled.main+xml",
XlsxKind::Template => "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml",
XlsxKind::MacroTemplate => "application/vnd.ms-excel.template.macroEnabled.main+xml",
}
}
/// Whether this kind may carry a VBA project (`.xlsm`, `.xltm`).
pub fn is_macro_enabled(self) -> bool {
matches!(self, XlsxKind::MacroWorkbook | XlsxKind::MacroTemplate)
}
}
/// Writes a workbook as an `.xlsx` package, or as an `.xlsm` one when it holds a VBA project.
pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
write::write_xlsx(wb)
write::write_xlsx(wb, if wb.vba_project.is_some() { XlsxKind::MacroWorkbook } else { XlsxKind::Workbook })
}
/// Writes a workbook as the given member of the `.xlsx` family.
pub fn write_xlsx_as(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
write::write_xlsx(wb, kind)
}
/// CSV import options.
+2 -2
View File
@@ -176,10 +176,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
};
wb.calc.iterative = c.flag("iterate", false);
if let Some(n) = c.attr_u32("iterateCount") {
wb.calc.max_iterations = n.clamp(1, 32767);
wb.calc.set_max_iterations(n);
}
if let Some(d) = c.attr_f64("iterateDelta") {
wb.calc.max_change = d.abs();
wb.calc.set_max_change(d);
}
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
}
+8 -3
View File
@@ -184,7 +184,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
if let Some(rel) = tp.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)) {
let target = rel.target.clone();
if let Some(x) = cx.optional_xml(&target)?
&& let Some(mut t) = read_table(&x)
&& let Some(mut t) = read_table(cx, &x)
{
t.id = cx.next_object_id();
sheet.tables.push(t);
@@ -454,7 +454,8 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
}
fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option<Formula> {
let text = f.text.trim();
let model = from_file(f.text.trim());
let text = model.as_str();
match f.attr("t").unwrap_or("normal") {
"shared" => {
let si = f.attr_u32("si")?;
@@ -777,6 +778,9 @@ pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option<AutoFilter> {
} else if let Some(c) = fc.child("colorFilter") {
let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue };
if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) }
} else if let Some(i) = fc.child("iconFilter") {
let Some(set) = i.attr("iconSet") else { continue };
FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") }
} else {
continue;
};
@@ -785,7 +789,7 @@ pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option<AutoFilter> {
Some(AutoFilter { range, criteria })
}
pub fn read_table(x: &El) -> Option<Table> {
pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option<Table> {
let range = x.attr("ref").and_then(RangeRef::parse)?;
let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string();
let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0;
@@ -836,6 +840,7 @@ pub fn read_table(x: &El) -> Option<Table> {
first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)),
last_col: si.is_some_and(|s| s.flag("showLastColumn", false)),
filter_button: x.child("autoFilter").is_some(),
filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(),
})
}
+24 -19
View File
@@ -5,8 +5,8 @@ use std::fmt::Write as _;
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind,
Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind, Style, Table,
TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
};
use crate::drawing::{Obj, image_content_type, image_ext};
@@ -35,10 +35,12 @@ fn cf_op_name(op: CfOperator) -> &'static str {
}
}
/// Valid table name: letters, digits, `_` and `.`, starting with a letter or `_`.
/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\`
/// (Microsoft "Names in formulas" rules).
fn table_name(name: &str, used: &mut HashSet<String>, n: u32) -> String {
let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || c == '_' || c == '.' { c } else { '_' }).collect();
if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') || gridcraft_core::CellRef::parse(&s).is_some() {
let mut s: String =
name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect();
if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() {
s = format!("Table{n}");
}
let mut out = s.clone();
@@ -56,12 +58,7 @@ fn table_columns(t: &Table) -> Vec<String> {
let mut used = HashSet::new();
(0..w)
.map(|i| {
let base = t
.columns
.get(i)
.map(|c| c.name.replace(['\r', '\n'], " "))
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| format!("Column{}", i + 1));
let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
@@ -346,7 +343,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
if let Some(af) = &sheet.autofilter
&& !sheet.tables.iter().any(|t| t.range.intersects(&af.range))
{
s.push_str(&autofilter_xml(af, out));
s.push_str(&autofilter_xml(af.range, &af.criteria, out));
}
if !sheet.merges.is_empty() {
let merges: Vec<&RangeRef> = sheet.merges.iter().filter(|m| !m.is_single()).collect();
@@ -569,7 +566,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
out.table_id += 1;
let n = out.tables;
let name = table_name(&t.name, &mut used_names, out.table_id);
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), table_xml(t, cols, &name, out.table_id).into_bytes());
let xml = table_xml(t, cols, &name, out.table_id, out);
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes());
let rid = rels.add("table", &format!("../tables/table{n}.xml"));
let _ = write!(parts, "<tablePart r:id=\"{rid}\"/>");
}
@@ -911,14 +909,14 @@ fn filter_operator(op: &str) -> &'static str {
}
}
fn autofilter_xml(af: &AutoFilter, out: &mut Out) -> String {
let mut s = format!("<autoFilter ref=\"{}\"", af.range.a1());
if af.criteria.is_empty() {
fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String {
let mut s = format!("<autoFilter ref=\"{}\"", range.a1());
if criteria.is_empty() {
s.push_str("/>");
return s;
}
s.push('>');
for (col, c) in &af.criteria {
for (col, c) in criteria {
let _ = write!(s, "<filterColumn colId=\"{col}\">");
match c {
FilterCriterion::Values { values, blanks } => {
@@ -951,6 +949,13 @@ fn autofilter_xml(af: &AutoFilter, out: &mut Out) -> String {
st.font.color = *c;
let _ = write!(s, "<colorFilter dxfId=\"{}\" cellColor=\"0\"/>", dxf_id(&mut out.dxfs, &st));
}
FilterCriterion::Icon { set, id } => {
let _ = write!(s, "<iconFilter iconSet=\"{}\"", esc_attr(set));
if let Some(id) = id {
let _ = write!(s, " iconId=\"{id}\"");
}
s.push_str("/>");
}
}
s.push_str("</filterColumn>");
}
@@ -958,7 +963,7 @@ fn autofilter_xml(af: &AutoFilter, out: &mut Out) -> String {
s
}
fn table_xml(t: &Table, cols: &[String], name: &str, id: u32) -> String {
fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String {
let mut s =
format!("{XML_DECL}<table xmlns=\"{NS_MAIN}\" id=\"{id}\" name=\"{0}\" displayName=\"{0}\" ref=\"{1}\"", esc_attr(name), t.range.a1());
if !t.header_row {
@@ -973,7 +978,7 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32) -> String {
if t.header_row && t.filter_button {
let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row };
let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col));
let _ = write!(s, "<autoFilter ref=\"{}\"/>", r.a1());
s.push_str(&autofilter_xml(r, &t.filter, out));
}
let _ = write!(s, "<tableColumns count=\"{}\">", cols.len());
for (i, name) in cols.iter().enumerate() {
+262
View File
@@ -0,0 +1,262 @@
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
//!
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
//! use: a fixed key protects nothing.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use crate::crypto::{
BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash,
standard_key,
};
/// The spin count `encrypt_workbook` uses: Excel's default.
pub const SPIN_COUNT: u32 = 100_000;
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
const SECRET_KEY: [u8; 32] =
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
/// (AES-256, SHA-512) inside a compound file.
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT);
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
}
/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it.
pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec<u8> {
let (info, encrypted) = encrypt_standard(package, password);
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
}
/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as
/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular
/// sectors. Up to 109 FAT sectors (about 7 MB of streams).
pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
const SECTOR: usize = 512;
const MINI: usize = 64;
const CUTOFF: usize = 4096;
const FREE: u32 = 0xFFFF_FFFF;
const END: u32 = 0xFFFF_FFFE;
const FAT_SECTOR: u32 = 0xFFFF_FFFD;
let is_mini = |d: &[u8]| d.len() < CUTOFF;
// The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT.
let mut container = vec![];
let mut mini_fat: Vec<u32> = vec![];
let mut mini_starts = vec![];
for (_, d) in streams {
if !is_mini(d) || d.is_empty() {
mini_starts.push(END);
continue;
}
let first = mini_fat.len();
let n = d.len().div_ceil(MINI);
for k in 0..n {
mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 });
}
mini_starts.push(first as u32);
container.extend_from_slice(d);
container.resize((first + n) * MINI, 0);
}
// Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream.
let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR;
let mut blobs: Vec<Vec<u8>> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()];
blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() }));
let counts: Vec<usize> = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect();
let body: usize = counts.iter().sum();
let mut fat_sectors = 1;
while fat_sectors * 128 < fat_sectors + body {
fat_sectors += 1;
}
assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors");
let mut fat = vec![FREE; fat_sectors * 128];
fat[..fat_sectors].fill(FAT_SECTOR);
let mut starts = vec![];
let mut next = fat_sectors;
for &n in &counts {
if n == 0 {
starts.push(END);
continue;
}
starts.push(next as u32);
for k in 0..n {
fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 };
}
next += n;
}
let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes());
let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
// Directory: the root (owning the mini stream), then the streams chained as right siblings.
let dir = &mut blobs[0];
for k in 0..dir_len / 128 {
for at in [68, 72, 76] {
put32(dir, k * 128 + at, FREE);
}
}
let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| {
let e = k * 128;
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
dir[e..e + units.len()].copy_from_slice(&units);
put16(dir, e + 64, units.len() as u16 + 2);
dir[e + 66] = kind;
dir[e + 67] = 1; // black
if kind == 5 && !streams.is_empty() {
put32(dir, e + 76, 1);
}
if kind == 2 && k < streams.len() {
put32(dir, e + 72, k as u32 + 1);
}
put32(dir, e + 116, start);
put32(dir, e + 120, size as u32);
};
entry(0, "Root Entry", 5, starts[2], container.len());
for (k, (name, data)) in streams.iter().enumerate() {
let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] };
entry(k + 1, name, 2, start, data.len());
}
let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)];
out[..8].copy_from_slice(&crate::cfb::SIGNATURE);
put16(&mut out, 24, 0x3E); // minor version
put16(&mut out, 26, 3); // major version
put16(&mut out, 28, 0xFFFE); // byte order
put16(&mut out, 30, 9); // 512-byte sectors
put16(&mut out, 32, 6); // 64-byte mini sectors
put32(&mut out, 44, fat_sectors as u32);
put32(&mut out, 48, starts[0]);
put32(&mut out, 56, CUTOFF as u32);
put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none)
put32(&mut out, 64, counts[1] as u32);
put32(&mut out, 68, END); // no DIFAT sectors
for i in 0..109 {
put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE });
}
for (i, v) in fat.iter().enumerate() {
put32(&mut out, SECTOR + i * 4, *v);
}
for (blob, start) in blobs.iter().zip(&starts) {
if !blob.is_empty() {
let at = SECTOR * (1 + *start as usize);
out[at..at + blob.len()].copy_from_slice(blob);
}
}
out
}
fn encrypt_block(key: &[u8], block: &mut [u8; 16]) {
let b = aes::Block::cast_from_core_mut(block);
match key.len() {
16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b),
32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b),
n => panic!("no {n}-byte AES in the test encryptor"),
}
}
fn pad(data: &[u8]) -> Vec<u8> {
let mut v = data.to_vec();
v.resize(data.len().div_ceil(16) * 16, 0);
v
}
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut v = pad(data);
v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b));
v
}
fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
let mut v = pad(data);
let mut prev: [u8; 16] = iv.try_into().unwrap();
for b in v.as_chunks_mut::<16>().0 {
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
encrypt_block(key, b);
prev = *b;
}
v
}
fn base64_encode(b: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in b.chunks(3) {
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
for i in 0..4 {
s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
}
}
s
}
fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
let mut p = (size as u64).to_le_bytes().to_vec();
p.extend(body);
p
}
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16.
pub(crate) fn agile_info(password: &str, spin: u32) -> String {
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
let iv = key.iv(None);
let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data));
let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER);
let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER]));
let secret = field(&BLOCK_KEY, &SECRET_KEY);
let params = |salt: &[u8]| {
format!(
"saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"",
base64_encode(salt)
)
};
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
params(&KEY_DATA_SALT),
params(&PASSWORD_SALT),
)
}
/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML.
pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
info.extend_from_slice(xml.as_bytes());
info
}
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512).
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
let mut body = vec![];
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
}
(with_agile_header(&agile_info(password, spin)), package(plain.len(), body))
}
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec<u8>, Vec<u8>) {
let key = standard_key(password, &PASSWORD_SALT, 16);
let mut header = vec![];
for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] {
header.extend_from_slice(&u32::to_le_bytes(v));
}
header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes));
let mut info = vec![4, 0, 2, 0];
info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes());
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
info.extend(header);
info.extend_from_slice(&16u32.to_le_bytes());
info.extend(PASSWORD_SALT);
info.extend(encrypt_ecb(&key, &VERIFIER));
info.extend_from_slice(&20u32.to_le_bytes());
info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER])));
(info, package(plain.len(), encrypt_ecb(&key, plain)))
}
+2
View File
@@ -183,6 +183,7 @@ fn calculated_table_column_keeps_formula_and_cell_value() {
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
});
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
@@ -330,6 +331,7 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() {
first_col: false,
last_col: false,
filter_button: true,
filter: vec![],
});
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
sh.cells.set(at("B2"), formula("[@Input]*2", 6.0));

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