Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick c217639cb5 test: share migrated GridCraft UI harness setup 2026-10-25 09:21:03 +01:00
skylar taylor-barrick e9764f6446 M0.37: fix RTL clipping and preserve screenshot budgets 2026-10-25 08:53:00 +01:00
skylartaylor 7a65d373c1 M0.37: reconcile GridCraft main and qualify the final UI port 2026-10-25 07:55:28 +01:00
aiongg 52fa4406c1 Merge pull request #2538 from aiongg/dcc-batch
Merge 18 open PRs (tested batch)
2026-10-25 00:53:54 -04:00
aiongg 24e49d18ad Batch: Subtotal puts summary rows below their detail in the same undo step (#2482) 2026-10-25 04:27:21 +00:00
aiongg 850dc9c772 Batch: the cell menu offers no Show Details while a PivotTable has it turned off (#2467) 2026-10-25 04:26:22 +00:00
aiongg f17179ceeb Batch: Show Details turned off says so in plain words; the option is in the command's docs (#2461) 2026-10-25 04:22:02 +00:00
aiongg 47fadb8dcf Batch: Go To lists its entries once when it opens and lays out only the rows in view (#2414) 2026-10-25 04:16:00 +00:00
aiongg 87a649c763 Batch: Paste Name lists the names once, finds local names by hash and refuses a name it doesn't list (#2410) 2026-10-25 03:56:45 +00:00
aiongg 77b37df655 Batch: the status bar names a circular reference by its address alone (#2510) 2026-10-25 03:53:01 +00:00
aiongg ffa08d3a31 Batch: Circular References submenu quotes sheet names that need quotes (#2501) 2026-10-25 03:52:28 +00:00
aiongg 50c7ee3971 Batch: undo, redo and rollbacks list the circular references of the restored cells (#2501) 2026-10-25 03:50:42 +00:00
aiongg f65e9f6bed Merge upstream main into the batch
# Conflicts:
#	crates/ui-egui/src/formula_bar.rs
#	crates/ui-egui/src/ribbon.rs
#	crates/ui-egui/tests/it/main.rs
2026-10-25 03:34:46 +00:00
Pranav e1a12fa337 UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget (#456)
* UI: theme tokens for icon, reference and sheet-control colours; WCAG contrast test

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

An XLSX area chart with percentStacked grouping now opens as 100%
stacked and is saved back as percentStacked. It used to open as a
stacked area and was saved as stacked.
2026-10-24 16:42:59 -07:00
dotCooCoo a2e4b2b1da Error Checking: the Circular References submenu scrolls
The submenu lists up to 100 cells. When the list was taller than the
window, the cells below its bottom edge could not be reached. The list
now sits in a scroll area 400 points high.
2026-10-24 15:48:55 -07:00
dotCooCoo a75224617b Status bar: Circular References names a cell only when a loop is on the active sheet
The status bar named the first circular reference in the list, even
when it was on another sheet. It now names the first one on the active
sheet, and shows only Circular References when every loop is on other
sheets, as Excel's status bar does.
2026-10-24 15:43:52 -07:00
dotCooCoo 1dd07b7f75 Circular references: Error Checking and the status bar quote sheet names that need quotes
formulas.errorChecking listed each circular reference as Sheet!A1 with
the sheet name as it is, so a sheet named Bob's Q1 gave Bob's Q1!B2,
which a formula does not accept as a reference. The list now quotes the
name with quote_sheet, as references are written elsewhere in the
engine ('Bob''s Q1'!B2), and the status bar's Circular References
label uses the same form.
2026-10-24 14:51:06 -07:00
skylartaylor ada96c896d egui037: record final published pins and local qualification 2026-10-24 23:30:02 +02:00
dotCooCoo 71669dae6d Error Checking: its arrow opens a Circular References submenu listing a cell on each loop
Formulas > Error Checking is now a split button. The button opens the
Error Checking dialog as before, and its arrow opens a menu with Error
Checking… and Circular References. Circular References lists a cell on
each circular reference as Sheet!A1, up to 100, and picking one selects
it on its own sheet and scrolls the grid to it. With no circular
reference the item is unavailable.

The calculation kept only the loops its latest run found, so typing in
any cell outside a loop emptied the list, and the status bar's Circular
References with it, while the loop was still there. A partial
recalculation now keeps the loops among the formulas it does not
evaluate. In automatic calculation a loop leaves the list once an edit
breaks it. In manual calculation only the edited formulas run, so a
listed cell stays until it is edited or the workbook is recalculated.
A full recalculation or iterative calculation builds the list again.
2026-10-24 14:28:19 -07:00
skylartaylor f86e93ad91 egui037: reconcile current main and finalize published dependency pins 2026-10-24 23:21:27 +02:00
dotCooCoo b745c136b2 Headers and footers: printing follows the Scale with Document and Align with Page Margins check boxes
The Header & Footer tab now has the Scale with Document and Align with
Page Margins check boxes, and pageLayout.headerFooter takes
scaleWithDoc and alignWithMargins. XLSX already kept both settings,
but nothing else read them.

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

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

Each open workbook keeps its own share while it is open. The share is
not saved in the file, and a workbook starts at the default.
2026-10-24 10:01:58 -07:00
dotCooCoo 28efc9dabf Docs: list the date fill modes in the ui-parity history row without table pipes 2026-10-24 09:25:36 -07:00
dotCooCoo 3fa6cb470b PivotTables: right-clicking a value cell offers Show Details in the cell menu
The cell menu of a PivotTable's value, subtotal or grand total cell now
has a Show Details item after Sort Z to A. It runs pivot.showDetails on
the active cell, which lists the cell's source records on a new sheet,
as double-clicking the cell does. Labels and headers of a current
report, and cells outside a report, do not offer it. In a report that
is out of date every cell of the report offers the item, and running it
shows the message that asks for a refresh.

Whether the menu offers the item is cached per document, revision,
sheet and active cell.
2026-10-24 09:04:33 -07:00
dotCooCoo 30126a709a PivotTables: Enable show details turns Show Details off for a PivotTable
A PivotTable has an Enable show details option, set with the new
pivot.options command ({pivot?, enableShowDetails?: bool}). While it is
off, pivot.showDetails and double-clicking a value cell list no records:
the double-click shows a message and does not open the cell for editing.
Labels and headers still edit. Changing the option is one undo step and
does not refresh the report. pivot.fields reports the setting.

XLSX files keep it: an off option is written as enableDrill="0" on the
pivotTableDefinition, and enableDrill is read on open (on when absent).
2026-10-24 07:46:29 -07:00
dotCooCoo 46f6157800 Merge main into fix/lambda-recursion-stack 2026-10-24 07:29:00 -07:00
dotCooCoo 2ae2329090 Docs: drop the stale missing-keys row that still listed Ctrl+6
Ctrl+6 hides and shows objects, and the row above already lists the other missing keys (Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P).
2026-10-24 07:26:48 -07:00
dotCooCoo 00edfd0690 Fill handle: Fill Days, Fill Weekdays, Fill Months and Fill Years for a date
When a fill starts from a date, the right-drag fill menu and the Auto
Fill Options button now also offer Fill Days, Fill Weekdays, Fill Months
and Fill Years. edit.autoFill takes them as the modes days, weekdays,
months and years.

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

Fills by months or years now keep the time of day of a date with a
time. A plain drag of dates a month apart and Series by Month or Year
used to drop it.
2026-10-24 06:56:26 -07:00
Dylan M. Taylor 3099df934e M7: verify Agile encryption's dataIntegrity before returning plaintext (#2425)
Agile decryption checked only the password verifier, which authenticates
the key — not the package. An attacker who could replace EncryptedPackage
ciphertext could change workbook content undetected ([MS-OFFCRYPTO]
2.3.4.14, the descriptor's dataIntegrity element).

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

- crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key
  longer than the block size
- fixtures write the pair like Excel; new tests: missing/empty/ragged
  integrity fields, tampered ciphertext and descriptor, a changed
  StreamSize field
2026-10-24 07:58:13 -04:00
dotCooCoo d0fe8b6423 Tests: the engine's file-system tests each use their own temporary folder
save_fails_upfront_when_the_temp_name_is_taken,
save_keeps_the_destination_access_restrictions and
open_refuses_directories_and_unknowable_lengths in crates/engine/src/io.rs
all worked in temp_dir()/gridcraft-fs-safety and removed it at the start
and the end. cargo test runs them on parallel threads, so one test could
delete the folder while another was writing to it, and the other test
failed. Each test now uses its own folder, named with a short label and
the process id, as the gridcraft-write and gridcraft-sandbox tests in the
same file do.
2026-10-24 04:54:53 -07:00
dotCooCoo 07890b47f8 Keyboard: Ctrl+Shift+U expands and collapses the formula bar instead of underlining
Ctrl+Shift+U ran the Ctrl+U shortcut on Windows and Linux and underlined
the selection. In Excel it expands or collapses the formula bar, on
Windows and as Control+Shift+U on a Mac. It now does that on the grid
and in the cell editor or formula bar while editing, and the entry stays
open.

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

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

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

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

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

🤖 Generated with Codebuff

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

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

The choice is a preference set with the new view.statusBar command and
kept in prefs.json. A prefs.json without it keeps the defaults.
2026-10-24 02:39:50 -07:00
Dylan M. TaylorandCodebuff f284f6a825 M2.9: budget lambda work and bound wildcard substring search (#2415)
Sweep findings from the ac8da7d4 security scan (storytold-gridcraft-agent-
findings.json), remaining on main at 72c1d57d:

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

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

🤖 Generated with Codebuff

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

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

Use in Formula lists the same names, and formulas.useInFormula inserts
at the caret instead of replacing the text being edited.
2026-10-23 23:47:38 -07:00
Brandon ThomasandClaude Opus 5.5 72c1d57d1e Review fixes for #2393, #2394, #2397 and #2399
- PMT: take rate / m first so (pv + fv) / m can't overflow for a tiny rate (PMT(1e-300,10,1e12)).
- Keytips probe: an empty params object keys like no params.
- Number Stored as Text check reads the same accounting parentheses as typed entry.
- Lookups: a NaN number falls back to the general comparison instead of comparing equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:12:07 -04:00
Brandon Thomas beda2a44dc Merge PR #2394 2026-10-24 02:10:42 -04:00
Brandon Thomas ec841a0139 Merge PR #2388 2026-10-24 02:10:42 -04:00
Brandon Thomas edea52e663 Merge PR #2402 2026-10-24 02:10:42 -04:00
Brandon Thomas 2c6e529b4d Merge PR #2400 2026-10-24 02:10:42 -04:00
Brandon Thomas d743f21992 Merge PR #2399
# Conflicts:
#	ROADMAP.md
#	docs/function-parity.md
2026-10-24 02:10:42 -04:00
Brandon Thomas f9af0d88a5 Merge PR #2397
# Conflicts:
#	ROADMAP.md
#	docs/function-parity.md
2026-10-24 02:10:42 -04:00
Brandon Thomas 14d6dabcaf Merge PR #2396 2026-10-24 02:10:36 -04:00
Brandon Thomas 05b77987cd Merge PR #2393 2026-10-24 02:10:36 -04:00
Brandon Thomas deb167956c Merge PR #2392 2026-10-24 02:10:36 -04:00
Pranav 6ead1fb519 Engine: building blocks for multi-command actions (undo groups, recording buffer, document trust, chunked hidden names) (#462)
* Engine: undo groups make several commands one undo step

Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.

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

Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.

* Engine: a recording buffer of top-level commands, apart from the journal

A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.

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

Automate › Record Actions keeps recording through the journal as before.

* Engine: document trust for workbooks opened from files

Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.

The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.

* Engine: workbook data in chunked hidden names (cmd/stash.rs)

GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.

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

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

docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.

* Engine: a linked paste of values from a file carries the file's trust

A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.

* Engine: a stash item with only later chunks reads as missing and falls back to the legacy name

With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists.

* Engine: a command that runs its own undo groups makes one undo step for itself

execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.

* Engine: a full undo history keeps one step per command that runs its own undo groups

When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.

The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.

* Engine: pasting only formats from a file leaves the destination's trust alone

Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.

Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.

* Engine: a command keeps its group's name only for a group it ended itself

The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.

The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.

* Engine: a group rolled back inside a command keeps the redo history

A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.

* Engine: a visible name of the item shape is not workbook data

The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.

* Engine: a command keeps its group's name only for a step that is exactly the group's

The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.

The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.

* Engine: the recording buffer is bounded in bytes and keeps the start of a recording

The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.

* Docs: the architecture revision row carries the date the change lands; document trust beside script trust

The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).

* Engine: writing a stash item checks for a clashing visible name in one pass over the names

The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.

* Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own

Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.

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

The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.

* Engine: the undo steps made inside a command are bounded while it runs

The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.

Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.

* Engine: a grouped action that closes its document succeeds

Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.

* Docs: undo groups at a command's boundaries, and the bound on steps made inside a command

* Engine: a linked paste carries the file's trust whatever it pastes

Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.

* Engine: a stash chunk number with leading zeros names someone else's name

`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.

* Engine: a command that panics leaves the undo history as it found it

The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.

* Engine: a headless call that fails for want of a file is not recorded

file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.

* Engine: a later stash chunk on its own is not a listed item

stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.

* Tests: a linked paste of column widths or notes copies its metadata and no cell

The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.

* Engine: the panic guard restores entries a nested Undo removed

Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.

* Engine: a group a panicking command ended keeps its step

A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.

* Docs: list every paste that carries a file's trust

The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.

* Engine: a grouped action that panics is rolled back before the panic goes on

Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.

* Engine: the recorder counts a dropped step without serializing it

Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.

* Engine: a failed command rolls back the undo groups it opened

A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.

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

`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.

* Engine: the recorder skips a call that only opened a dialog

A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.

`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.

* Engine: stash items keep characters XML can't carry

The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.

`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.

* Engine: list stash items in one pass over the names

Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.

`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.

* Engine: the name commands leave GridCraft's data names alone

`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.

Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.

* Engine: queries take no copy of the undo history

`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.

The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.

* Engine: a document's maker says where its content came from

`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.

`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.

* Engine: the name commands refuse only names that hold GridCraft's data

The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.

* UI: the toolbar's Undo and Redo follow the commands' own availability

The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.

* Engine: the name commands use the stash's own rule for what holds its data

The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.

* Docs: the panic guard's note says what happens to a group the command ended

It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
2026-10-24 02:06:44 -04:00
skylartaylor 33393aa3d8 E37: migrate GridCraft to egui 0.37
Pin the reviewed fork and shared dependencies, adapt input and rendering to the
new frame lifecycle, and preserve bounded capture handling and font notices.
Add representative accessibility checks and meaningful control labels.

Validation: full repository CI passed all ten stages, with 2,249 tests passed,
zero failed and one existing timing test ignored. Native workflows and paired
performance qualification remain documented separately.
2026-10-24 07:34:46 +02:00
dotCooCoo c22787e5bf Merge main into fix/lambda-recursion-stack 2026-10-23 19:32:12 -07:00
Helen Kwok e93d15b50a Contributors: credit helenkwok as Helen Kwok 2026-10-24 10:17:32 +08:00
Helen Kwok b389d9067a XLSX: write accounting formats 42 and 44 with their code
builtin_id already wrote the currency formats 5-8 as custom formats
because readers resolve those ids by locale. The accounting formats
with a currency, 42 and 44, are resolved the same way but were written
as bare ids, so LibreOffice showed a 44 cell holding -0.14 as "- 0.14"
where US Excel shows "$ (0.14)". Treat them like 5-8.
2026-10-24 10:17:32 +08:00
NetterSilverandClaude Haiku 5.5 b47c83f6aa Reject unmatched '.' and ',' in NUMBERVALUE text
Found while testing crates/functions/src/text.rs:326-340

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:48:12 +00:00
Harrison Booth 35067fa9ca Read accounting negatives like $(1,234) in number text
Typed entry, VALUE and arithmetic now read $(1,234), $ (5) and (5)% as
negative numbers, as Excel 365 does. Parentheses no longer nest: (-5),
((5)), $5% and dates in parentheses stay text. This changes main's
((100)) from 100 to text; the no-recursion guard from #2180 is kept and
its test now asserts the new result.
2026-10-23 16:57:24 -05:00
Harrison Booth 86f4114514 Merge remote-tracking branch 'origin/main' into gc-lookup-exact-number-compare
# Conflicts:
#	ROADMAP.md
#	docs/function-parity.md
2026-10-23 16:55:41 -05:00
Harrison Booth e883c84e7b Merge origin/main into the Analysis ToolPak value-argument branch
Resolves conflicts in ROADMAP.md, docs/function-parity.md and
crates/calc/src/tests.rs: both sides' progress-log rows and tests are kept.
2026-10-23 16:55:37 -05:00
Harrison Booth 9fd7693d4e Merge fork's border keytips fix into main 2026-10-23 16:39:13 -05:00
Harrison Booth 1ce4781d3e Accuracy: PMT to Excel's last bits via ln_1p/exp_m1
PMT computed (1 + rate)^nper with powf, which rounds 1 + rate first and
drops the low bits of a small rate. Over 400 synthetic probes that left
PMT up to about 1000 ULPs from Excel (59 bit-exact); the ln_1p/exp_m1 form
is within 4 ULPs (244 bit-exact). A loan schedule guarded by
IF(p - SUM(principal) > 0, ...) turns on those bits: in 3000 synthetic
schedules main disagreed with Excel on the number of live periods in 1382,
the fix in 823.

Also follows Excel at the edges: a rate of -1 or less is #NUM!, a huge
nper gives -pv*rate instead of #NUM!, a tiny rate no longer collapses, and
a small negative rate keeps its payment. IPMT, PPMT, CUMIPMT and CUMPRINC
pick this up through pmt_calc. FV and PV are unchanged: Excel's own FV/PV
sit further from the exact value than the current powf form does.

The tests allow the one-ULP libm difference between platforms.
2026-10-23 16:24:18 -05:00
Harrison Booth 74b2bda75c Merge origin/main into gc-index-2d-ref-one-index 2026-10-23 16:02:22 -05:00
Dylan M. Taylor b8771d51fb Merge pull request #2390 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 16:59:11 -04:00
Dylan M. Taylor 2329507a8e ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:51:16 -04:00
Marco Arnulfo 4b2ee2e2dd Conditional formatting: New Rule remembers its sheet by name, so moving or inserting sheets while it is open keeps the rule there (#2348) 2026-10-23 22:07:22 +02:00
Brandon ThomasandClaude Opus 5.5 7cf44d0fbb Integration and review fixes for #2367, #2369 and #2387
- #2369: its test joins the single ui-egui integration-test binary (tests/it), as main now builds them.
- #2367: a cell set to empty text is a blank entry and doesn't grow the AutoFilter (CodeRabbit).
- #2387: MOD's range check also covers the sign-corrected remainder (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:05:43 -04:00
Marco Arnulfo 45f06f50da Conditional formatting: New Rule keeps the range it was opened for when pointing on another sheet (#2348) 2026-10-23 22:05:24 +02:00
Brandon Thomas 81fd0f6f7d Merge PR #2387 2026-10-23 16:05:03 -04:00
Brandon Thomas 68da23e2eb Merge PR #2386 2026-10-23 16:05:01 -04:00
Brandon Thomas fba4224ffc Merge PR #2375 2026-10-23 16:04:59 -04:00
Brandon Thomas 2a4cc2a5b6 Merge PR #2369 2026-10-23 16:04:58 -04:00
Brandon Thomas ca0c3afbab Merge PR #2367 2026-10-23 16:04:56 -04:00
Brandon Thomas 87935eb245 Merge PR #2366 2026-10-23 16:04:55 -04:00
Marco Arnulfo d5aa6fbcbc Conditional formatting: New Rule's formula box takes references clicked or dragged on the grid (#2348) 2026-10-23 21:56:39 +02:00
NetterSilverandClaude Haiku 5.5 c746eea876 Fix MOD returning wrong sign and magnitude for tiny divisors
Found while testing crates/functions/src/math.rs:305-319

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 21:43:22 +02:00
aiongg 15a705e234 i18n: missing translations are reported, never a failure (#2383)
A string a catalog lacks shows in English. Coverage, key-parity and
per-language count tests and tests of one language's exact wording are
removed; UI wiring tests take their expected text from the catalog.
cargo xtask i18n-report lists the missing strings per language and
always passes; cargo xtask ci runs it first.
2026-10-23 15:39:59 -04:00
Marco Arnulfo 43a5a3c435 PivotTables: Show Details checks the report is up to date before looking at the cell 2026-10-23 21:00:07 +02:00
aiongg 530a8cc810 CI: the gate builds with the release craft-fonts pin (#2381) 2026-10-23 14:47:33 -04:00
Marco Arnulfo 7111812b87 PivotTables: Show Details refuses an out-of-date report, and reports its errors instead of editing the cell 2026-10-23 20:41:47 +02:00
aiongg 08820cb4b2 CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#2338)
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 14:32:15 -04:00
Marco Arnulfo 0c14f59555 PivotTables: Show Details, double-click a value cell to list its source records on a new sheet (#2364) 2026-10-23 20:21:27 +02:00
aiongg aa8399be95 Grid: #### is sized by the width of #, so it fits its cell in every font (#2378)
A number too wide for its column is drawn as ####, with as many # as fit. The count used the
width of 0, and in fonts where # is wider than 0 the hashes ran past the cell edge.
accounting_fill::too_wide_fill_number_shows_hashes_not_a_clipped_value caught it on FreeBSD
and on Linux machines without the CI font set.
2026-10-23 14:14:15 -04:00
xc4pr 49f26bdd79 Fill handle: the Auto Fill Options button on a right-to-left sheet is checked in screen space (#2368) 2026-10-23 19:04:27 +02:00
xc4pr 39450b80d1 Hyperlinks: Ctrl+Alt+click on a link extends the selection again (#2368) 2026-10-23 19:04:27 +02:00
aiongg 33a45130a1 Packaging: pin the signing client and appimagetool, and check their hashes (#2073)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

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

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

* CI: one integration-test binary per crate

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

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

* CI: docs name cargo xtask test

* CI: agent guides name the moved fuzz test

* CI: tests added on main join their crate's test binary
2026-10-23 12:59:46 -04:00
Marco Arnulfo 45ec9dfbfe Build the saved UiState in the test with struct update syntax (clippy field_reassign_with_default) 2026-10-23 18:44:23 +02:00
MarekSpodBiedry 8f2a60d176 Keep the in-cell editor the cell's width while the text fits (#2336) 2026-10-23 17:56:16 +02:00
Dylan M. Taylor bdf8b8cc75 Use chrono instead of hand-rolled UTC conversion (#2121)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

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

Follow-up to storytold/cadcraft#1785

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:48:02 -04:00
MarekSpodBiedry 599762f534 AutoFilter: grow the range when a value is entered in the row below it (#2342) 2026-10-23 17:36:39 +02:00
MarekSpodBiedry ba98039988 Keep the in-cell editor wide enough for the next character at high zoom (#2336) 2026-10-23 17:33:31 +02:00
Dylan M. Taylor 168acc5889 packaging: shell, PowerShell and web page fixes (SonarCloud S7688, S7679, S7682, S8677, S7926) (#2323)
- Shell: [[ ]] for conditional tests, positional parameters copied into
  named locals, explicit return at the end of the env.sh helpers.
- PowerShell: the desktop-shortcut smoke test writes its progress with
  Write-Output instead of Write-Host.
- Web: the viewport no longer sets user-scalable=no, so people can zoom
  the page.
2026-10-23 11:17:38 -04:00
Marco Arnulfo a0ceea1468 Open on the Home tab every launch, as in Excel (fixes #2329) 2026-10-23 17:16:30 +02:00
Marco Arnulfo 9135c7f6f0 Ribbon: fall back to Home when the active contextual tab is gone (fixes #2329) 2026-10-23 17:07:42 +02:00
Brandon ThomasandClaude Opus 5.5 f1e29fa828 Review fixes for #2267 and #2292
- range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit).
- Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit).

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:20:18 -08:00
xc4pr 7c1ec1df7d Fill handle: test the Auto Fill Options button on a right-to-left sheet, merge the duplicate fill handle row (#1403) 2026-10-23 02:37:49 +02:00
xc4pr 60776ff4de Hyperlinks: test that Ctrl+click beside the link text still extends the selection (#1198) 2026-10-23 02:23:37 +02:00
xc4pr d19f656014 Fill handle: test the Auto Fill Options button's workbook-changed rule, report an undo error, document it (#1403) 2026-10-23 02:17:38 +02:00
xc4pr 6febf5442f Fill handle: Auto Fill Options button after a drag offers Copy Cells, Fill Series and the formatting modes (#1403)
A left-drag of the fill handle filled at once and offered no choice. A button now appears below the filled range; its menu undoes that fill and reruns edit.autoFill in the chosen mode. It goes away when the workbook changes or the selection moves.
2026-10-23 01:25:21 +02:00
xc4pr d046ba6aa7 Hyperlinks: Ctrl+click on a link follows it instead of extending the selection (#1198)
A Ctrl+click went to the add-to-selection branch, so the link under the pointer was never followed. It now goes through follow_link like a plain click (asks before opening web addresses, jumps to references).
2026-10-23 00:44:34 +02:00
D. Fender 09f78cb3a8 Tables: Format as Table inside a table restyles it
Home > Format as Table on a cell or range inside an existing table
failed with "A table can't overlap another table." Excel applies the
picked style to that table instead. The command now returns that table
and its range, as it does for a new table. A range crossing a table's
edge is still rejected.

Without a range, the active area of the selection decides, as it does
for Insert > Table. The native Format menu's Format as Table now runs
home.formatAsTable instead of insert.table.
2026-10-22 11:43:58 -07:00
dotCooCoo 450f457535 Calc: a formula calculated inside another one shares its stack budget
A cell that another cell's formula calculates on demand got its own
evaluator, which started a new 896 KiB stack budget wherever it was
called. Two cells that each count down from 131 with a LAMBDA, one read
at the deepest call of the other, overflowed a 1 MiB thread.

The budget of a formula calculated on demand is now counted from where
the outermost formula started. When it runs out there, the formula is
calculated again from the top, as a formula needed at the nesting limit
is, so its value does not depend on the order cells are calculated in.
2026-10-22 11:06:01 -07:00
coolvitto a8e2ed31b1 Add extra Japanese translations for UI elements
Added extra labels for ribbon menus and pop-ups in Japanese.
2026-10-23 03:05:42 +09:00
dotCooCoo a0fa6c6e6e Merge main into fix/lambda-recursion-stack 2026-10-22 10:57:09 -07:00
NetterSilverandClaude Haiku 5.5 9cbb674c55 Print numbers near f64::MAX in a form that parses back
Found while testing crates/formula/src/printer.rs:134

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:34:06 +02:00
Dominik StrebelandClaude Opus 5.5 74f29a743f docs: Ctrl+Shift+A (argument names) is no longer listed as missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:18:45 +02:00
Dominik StrebelandClaude Opus 5.5 e6ffca60bb view: hidden outline symbols answer no clicks; parity rows updated
With headings hidden too, a level button sat across the empty column band
at the grid's top edge, so a click there still ran it. Outline hit testing
now stops when both bands are empty. The parity doc no longer lists Ctrl+8
as missing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

An unknown key *inside* `params` is still ignored — the rest of #2256 — and
needs the command's parameter list, which the engine does not expose yet. Noted
on the issue.
2026-10-22 17:50:09 +05:00
NetterSilverandClaude Opus 5.5 8307d3cb05 Treat empty instance_num in TEXTBEFORE/TEXTAFTER as 1
Found while testing crates/functions/src/text.rs:553

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

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

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

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

Also adds my contributors/people.toml entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-21 23:41:28 -07:00
Viorel ToaderandClaude Opus 5.5 b9801f3f81 Romanian: translate Options|Editing; 22 strings main added since (sheet tab menu, account, chart and error-checking labels)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-22 07:55:04 +03:00
dotCooCoo b0ab19701d Merge main into fix/lambda-recursion-stack 2026-10-20 16:10:54 -07:00
dotCooCoo d0fdf948db Calc: a recursive LAMBDA stops at a fixed count of 140 calls
A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of
native stack, so the depth it reached depended on frame sizes. On a
Windows release build the deepest countdown was F(F,94), where 0.7.0
reached F(F,131).

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

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

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

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

Fixes #1662
2026-10-17 15:26:31 -07:00
Harrison BoothandClaude Opus 5.5 6db94d154a Calc: INDEX with one index on a 2-D reference is #REF!, as in Excel
INDEX(A1:C3,2) returned the whole second row. Excel gives #REF! when a
reference has several rows and columns and the column argument is
omitted, so the error also carries into ranges built from it
(A1:INDEX(A1:C3,2)), position arrays (INDEX(A1:C3,{1;2})) and outer
functions. An empty column argument (INDEX(A1:C3,2,)) still gives the
row, and arrays are unchanged: INDEX({1,2;3,4},2) is the row.

Observed in Excel that a column array with one index,
INDEX({10;20;30},2), is a 1x1 array (TYPE 64), which is what calc
already returned; the new test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 05:19:32 -05:00
Harrison BoothandClaude Opus 5.5 ea190adad6 functions: Analysis ToolPak value arguments reject multi-cell references
EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the
coupon, bond and T-bill functions, DISC, BIN2DEC, IM* and the other Analysis
ToolPak functions give #VALUE! in Excel 365 for a reference to more than one
cell in a value argument, in dynamic-array and legacy formulas alike, where
GridCraft lifted the range and spilled. Array constants and computed arrays
still lift, and range arguments (holidays, cash flows, schedules) and the
.INTL weekend still take ranges.

FnSpec gains a value_args mask, filled at registration from one table in
lift.rs; call_lifted turns a multi-cell reference at a value position into a
scalar #VALUE! before lifting, so EOMONTH(A1:A3,{1;2}) is a 2x1 array of
#VALUE! as in Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:55:46 -05:00
Harrison BoothandClaude Opus 5.5 5c20f16434 core: read accounting negatives like $(1,234) in number text
Excel 365 reads a dollar sign before accounting parentheses ($(1,234),
$ (5)) and a percent sign after them ((5)%) as negative numbers when the
text is typed, coerced by arithmetic or passed to VALUE. GridCraft gave
#VALUE! for these.

The parentheses branch no longer recurses: Excel reads only an unsigned
plain number or fraction inside, so (-5), ((5)), ($-5), and dates or
times in parentheses are now #VALUE! as in Excel (they used to give 5
or a negative serial). $5% is #VALUE! too: Excel never combines the
currency and percent signs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:30:46 -05:00
Harrison BoothandClaude Opus 5.5 ab4d58ca6f Lookups compare numbers exactly, like Excel
MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compared numbers at
15 significant digits (through compare / compare_numbers), so a computed
key such as =0.1+0.2 (0.30000000000000004) found 0.3. Excel 365 compares
lookup keys exactly in exact, approximate and binary search modes, while
=, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF,
MAXIFS) keep the 15-digit comparison. Observed in Excel via COM with
synthetic values.

lookup_equal now compares numbers with ==, and the lookup searches order
values with lookup_order (exact for numbers, compare otherwise).
compare_numbers and the criteria path are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:26:06 -05:00
Harrison BoothandClaude Opus 5.5 74de1b32eb Keytips: Alt, H, B border letters apply their own preset
Every Alt, H, B letter ran home.borders with no params, which defaults to
a bottom border, so H,B,N (No Border) and H,B,A (All Borders) both drew a
bottom border. keytips.json entries now take optional fixed `params`
(Action::CommandWith / DialogWith), and each Borders menu letter passes the
preset Excel's menu gives it. H,B,M opens Format Cells on the Border tab.
Erase Border, Line Color and Line Style are reserved (pending) instead of
drawing a bottom border.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:14:07 -05:00
310 changed files with 21368 additions and 2577 deletions
+13 -33
View File
@@ -1,8 +1,7 @@
# 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
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -12,37 +11,18 @@ on:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
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
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+6 -1
View File
@@ -2,6 +2,9 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [release]
@@ -10,6 +13,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "57 2 * * *"
workflow_dispatch:
concurrency:
@@ -24,4 +29,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: gridcraft
app-title: GridCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+2 -1
View File
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
+4
View File
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
+1 -1
View File
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
Generated
+1781 -99
View File
File diff suppressed because it is too large Load Diff
+28 -6
View File
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
version = "0.11.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/gridcraft"
[workspace.dependencies]
@@ -30,6 +30,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.41"
@@ -41,10 +43,10 @@ sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
@@ -53,7 +55,7 @@ sys-locale = "0.3"
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
epaint_default_fonts = "0.36"
epaint_default_fonts = "0.37"
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -66,12 +68,32 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+262 -2
View File
@@ -12,5 +12,265 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
Font License 1.1.
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://github.com/source-foundry/Hack
The following text is reproduced verbatim from Hack-Regular.txt:
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
The following text is reproduced verbatim from OFL.txt:
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
is distributed under the Ubuntu Font Licence, Version 1.0.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Upstream: https://design.ubuntu.com/font
The following text is reproduced verbatim from UFL.txt:
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+22 -1
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-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
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.
@@ -135,6 +135,17 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -146,10 +157,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
@@ -175,6 +189,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
@@ -220,6 +235,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
@@ -228,6 +246,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
@@ -243,6 +263,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | UI building blocks; progress log row |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
+57 -10
View File
@@ -248,10 +248,11 @@ impl Discovery {
/// (`~$…`) are skipped.
fn collect_inputs(inputs: &[String]) -> Discovery {
let mut out = Discovery::default();
let mut seen = std::collections::HashSet::new();
for i in inputs {
let p = PathBuf::from(i);
if p.is_dir() {
if !walk(&p, 0, &mut out) {
if !walk(&p, 0, &mut out, &mut seen) {
break;
}
} else if !out.add_file(p) {
@@ -268,7 +269,20 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
}
/// Returns false only when the global file budget stops traversal.
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
// A directory is visited once by identity (dev+inode on unix): several links to the same
// directory are aliases, not exponential branches of the scan. Other platforms keep the
// depth cap only.
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if let Ok(m) = std::fs::metadata(dir)
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
&& !seen.insert(id)
{
return true;
}
}
if depth > MAX_DEPTH {
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
return true;
@@ -301,7 +315,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
}
};
if metadata.is_dir() {
if !walk(&p, depth + 1, out) {
if !walk(&p, depth + 1, out, seen) {
return false;
}
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
@@ -981,6 +995,12 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
let names = zip_names(&mut zip);
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
// Several relationships name the same part; each fresh check decompressed it again, so
// repeated references to one highly compressible part multiplied the work. The verdict is
// remembered per (part, named content) and the decompression the package can cause is
// budgeted as a whole (finding: repeated decompression).
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
let mut budget: u64 = 512 * 1024 * 1024;
let mut kinds = BTreeMap::new();
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
@@ -994,7 +1014,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
if !external
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
&& !holds_something(&mut zip, part, kind)
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
{
continue;
}
@@ -1013,15 +1033,41 @@ fn rels_source_dir(key: &str) -> Option<String> {
/// Whether a part has content: an element under its root, or for comments (whose authors list
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
use quick_xml::events::Event;
let Ok(f) = zip.by_name(name) else { return true };
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
/// package's remaining checks may cause, and once it is spent the part counts as having content.
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
"comments" => Some(b"comment"),
"vmldrawing" => Some(b"shape"),
_ => None,
};
let marker = match wanted {
Some(b"comment") => "comment",
Some(_) => "shape",
None => "any",
};
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
return *v;
}
let v = holds_something_uncached(zip, name, marker, budget);
verdicts.insert((name.to_ascii_lowercase(), marker), v);
v
}
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
use quick_xml::events::Event;
let Ok(f) = zip.by_name(name) else { return true };
if *budget == 0 {
return true;
}
let take = f.size().min(MAX_XML_PART).min(*budget);
*budget -= take;
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
let wanted: Option<&'static [u8]> = match marker {
"comment" => Some(b"comment"),
"shape" => Some(b"shape"),
_ => None,
};
let mut buf = Vec::new();
let mut in_root = false;
loop {
@@ -1321,7 +1367,8 @@ mod tests {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
assert!(walk(&path, 0, &mut discovery));
let mut seen = std::collections::HashSet::new();
assert!(walk(&path, 0, &mut discovery, &mut seen));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
+33
View File
@@ -43,6 +43,8 @@ const USAGE: &str = "usage:
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
[--out FILE] [--print RANGE] [--quiet]
a script line is id={json} or {\"command\": \"id\", \"params\": {…}};
blank lines and lines starting with # or // are skipped
gridcraft-cli commands [--json] [--search X]
gridcraft-cli functions [--json] [--search X] [--category C]
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
@@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result<Option<(String, Value)>, String> {
}
if t.starts_with('{') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?;
// A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and
// `{"command": …, "name": "Report"}` ran the command with no parameters at all and the
// script still exited 0, so a typo made a valid, wrong workbook (#2256).
if let Some(o) = v.as_object() {
let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect();
if !unknown.is_empty() {
unknown.sort_unstable();
let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `"));
return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}"));
}
}
let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?;
return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({})))));
}
@@ -567,6 +580,26 @@ mod tests {
assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({}))));
assert_eq!(parse_command(" # comment").unwrap(), None);
assert!(parse_command("x={bad").is_err());
// A key this does not read is a mistake: the command used to run with no parameters and
// the script still exited 0, so a typo made a valid, wrong workbook (#2256).
for (line, named) in [
(r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"),
(r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"),
(r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"),
] {
let e = parse_command(line).expect_err("refused");
assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}");
assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}");
}
// Several at once are listed in one message, sorted.
let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused");
assert!(e.contains("unknown keys `args`, `name`"), "{e}");
// `id` is still the accepted alternative to `command`, and `params` still carries them.
assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({}))));
assert_eq!(
parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(),
Some(("home.insertSheet".into(), json!({"name": "R"})))
);
}
#[test]
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
@@ -206,3 +204,41 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
let _ = std::fs::remove_dir_all(dir);
}
/// A script line whose parameters are under a key `run` does not read used to run the command
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
#[test]
fn a_script_line_with_an_unknown_key_stops_the_run() {
let dir = tmpdir("script-keys");
let script = dir.join("s.jsonl");
let out = dir.join("t.xlsx");
let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap();
write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]);
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
let err = String::from_utf8_lossy(&o.stderr);
assert!(!o.status.success(), "the run must stop: {err}");
assert!(err.contains("s.jsonl:2"), "it names the line: {err}");
assert!(err.contains("unknown key `args`"), "and the key: {err}");
assert!(!out.exists(), "and writes no workbook: {err}");
// The forms that are read still work, in one script: `params`, `id=json`, and a comment.
write(&[
r#"{"command":"file.new"}"#,
r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#,
"# a comment",
r#"home.insertSheet={"name":"Report2"}"#,
]);
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = ok(&["info", out.to_str().unwrap()]);
assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}");
assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}");
// The usage text says what a line looks like, which is how this was found.
let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout);
assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}");
let _ = std::fs::remove_dir_all(&dir);
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, one module per file, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod text_to_columns_quotes;
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GridCraft</title>
<link data-trunk rel="icon" href="../../assets/app-icon/gridcraft.ico" />
<link data-trunk rel="rust" data-bin="gridcraft-web" data-wasm-opt="z" data-weak-refs />
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
{
// No usable saved language: keep the browser's, as the desktop keeps the system's.
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
app.ui = state;
app.ui = state.at_startup();
app.ui.language = language;
}
if let Some(s) = prefs.filter(readable)
+9 -5
View File
@@ -60,8 +60,10 @@ pub fn start() {
}
let mut app = SheetApp::new(session, services);
app.ui.system_theme = true;
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
// Follow the browser's language, except a CJK one. CJK coverage comes from
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
// cannot be assumed to render localized labels without tofu. Users can still
// choose a language explicitly.
let browser = gridcraft_ui_egui::i18n::Language::system();
if !matches!(
browser,
@@ -156,8 +158,8 @@ impl eframe::App for WebShell {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -214,7 +216,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
if let Some(w) = web_sys::window()
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
let _ = w.open_with_url_and_target(&url, "_blank");
// With `noopener` the new browsing context can't hold a reference back to the
// editor window and navigate it while the user reads the linked page.
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
}
})),
inbox: Some(inbox),
+3 -2
View File
@@ -13,18 +13,19 @@ workspace = true
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
rfd = { workspace = true }
arboard = { workspace = true }
+36 -19
View File
@@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -393,6 +399,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+3 -4
View File
@@ -40,13 +40,12 @@ 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);
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
// Files dropped on the window open as workbooks.
for f in std::mem::take(&mut raw.dropped_files) {
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
@@ -111,7 +110,7 @@ fn load_prefs(app: &mut SheetApp) {
{
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
let language = saved.unwrap_or(app.ui.language);
app.ui = ui;
app.ui = ui.at_startup();
app.ui.language = language;
}
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
+1 -1
View File
@@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Unhide Sheet…", "sheet.unhide", None),
("-", "", None),
("Conditional Formatting…", "dialog:manageRules", None),
("Format as Table", "insert.table", None),
("Format as Table", "home.formatAsTable", None),
],
),
(
+4 -1
View File
@@ -5,7 +5,10 @@ pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
+10
View File
@@ -93,3 +93,13 @@ sync_github_name = false
[people.bilalmughal]
sync_github_name = true
[people.tyrel]
real_name = "Tyrel Haveman"
[people.helenkwok]
real_name = "Helen Kwok"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+139 -10
View File
@@ -22,6 +22,11 @@ const FULL_SIZE_CELLS: u64 = 16_384;
/// range, so that formulas over them stay fast.
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
const MAX_DEPTH: usize = 400;
/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke
/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
const LAMBDA_FUEL: u64 = MAX_CELLS;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Area {
@@ -53,6 +58,24 @@ fn err(e: CellError) -> Ev {
Ev::V(Value::Error(e))
}
/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build.
/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132
/// calls in progress, so it still runs.
const MAX_LAMBDA_CALLS: usize = 140;
/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it,
/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames
/// above the formula. A cell that another cell's formula computes on demand shares the budget of the
/// outermost formula ([`Host::stack_top`]).
const STACK_BUDGET: usize = 896 * 1024;
/// The address of a local variable, which shows how far down the native stack this call is.
#[inline(always)]
pub(crate) fn stack_position() -> usize {
let marker = 0u8;
std::hint::black_box(&marker) as *const u8 as usize
}
/// What the evaluator needs from its surroundings.
pub trait Host {
fn workbook(&self) -> &Workbook;
@@ -80,6 +103,13 @@ pub trait Host {
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
false
}
/// While a formula is calculated on demand inside another one: `stack_position()` where the
/// outermost one started, which the stack budget is counted from.
fn stack_top(&self) -> Option<usize> {
None
}
/// Called when a formula's evaluation runs past the stack budget.
fn stack_exhausted(&mut self) {}
}
pub struct Evaluator<'h> {
@@ -88,6 +118,15 @@ pub struct Evaluator<'h> {
pub at: CellRef,
env: Vec<(String, Ev)>,
depth: usize,
/// LAMBDA calls in progress.
calls: usize,
/// `stack_position()` at the outermost `eval`.
stack_top: usize,
/// Lambda invocations this cell still has left. Every call spends one charge, so a
/// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays)
/// runs out of work after the same number of calls that a legitimately wide one could
/// make, instead of an amount of work that squares with array nesting (#2382).
fuel: u64,
}
struct FnCtx<'a, 'h> {
@@ -127,8 +166,20 @@ impl Ctx for FnCtx<'_, '_> {
_ => None,
})
.collect();
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
let mut ev = Evaluator {
host: &mut *self.ev.host,
sheet: area.sheet,
at,
env,
depth: self.ev.depth,
calls: self.ev.calls,
stack_top: self.ev.stack_top,
fuel: self.ev.fuel,
};
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
// The child's spend counts against this cell's budget.
self.ev.fuel = ev.fuel;
Some(v)
}
fn has_lambda(&self) -> bool {
self.lambda.is_some()
@@ -147,7 +198,7 @@ impl Ctx for FnCtx<'_, '_> {
impl<'h> Evaluator<'h> {
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
}
/// Evaluates to a value (references are dereferenced; multi-cell → array).
@@ -233,10 +284,18 @@ impl<'h> Evaluator<'h> {
}
pub fn eval(&mut self, e: &Expr) -> Ev {
let here = stack_position();
if self.depth == 0 {
self.stack_top = self.host.stack_top().unwrap_or(here);
}
if self.stack_top.abs_diff(here) > STACK_BUDGET {
self.host.stack_exhausted();
return err(CellError::Num);
}
self.depth += 1;
if self.depth > MAX_DEPTH {
self.depth -= 1;
return err(CellError::Calc);
return err(CellError::Num);
}
let r = self.eval_inner(e);
self.depth -= 1;
@@ -520,7 +579,7 @@ impl<'h> Evaluator<'h> {
}
_ => {
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
// of one level of recursion per operator (past the depth limit it was #CALC!).
// of one level of recursion per operator (past the depth limit it was an error).
let mut rest = vec![(op, b)];
let mut left = a;
while let Expr::Binary(o, l, r) = left
@@ -829,10 +888,11 @@ impl<'h> Evaluator<'h> {
fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
let Some(spec) = gridcraft_functions::lookup(name) else {
// A defined name holding a LAMBDA, or a LET-bound lambda.
if let Ev::L(l) = self.name(name) {
return self.apply_lambda(&l, args);
}
return err(CellError::Name);
return match self.name(name) {
Ev::L(l) => self.apply_lambda(&l, args),
Ev::V(Value::Error(x)) => err(x),
_ => err(CellError::Name),
};
};
if args.len() < spec.min_args || args.len() > spec.max_args {
return err(CellError::Value);
@@ -983,10 +1043,22 @@ impl<'h> Evaluator<'h> {
}
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
if self.calls >= MAX_LAMBDA_CALLS {
return err(CellError::Num);
}
// #2382: one charge per invocation, so a cell's total work stays bounded however the
// lambdas nest. Foregrounding the charge here means every path into a body (direct
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
self.fuel = match self.fuel.checked_sub(1) {
Some(f) => f,
None => return err(CellError::Calc),
};
self.calls += 1;
let saved = std::mem::replace(&mut self.env, l.env.clone());
self.env.extend(bound);
let r = self.eval(&l.body);
self.env = saved;
self.calls -= 1;
r
}
@@ -1301,7 +1373,13 @@ impl<'h> Evaluator<'h> {
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
let r = a.range;
// A single row or column with one index picks along it.
// A single row or column with one index picks along it. An area with several rows
// and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third
// argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ:
// `INDEX({1,2;3,4},2)` is the row.
if two_args && r.height() > 1 && r.width() > 1 {
return err(CellError::Ref);
}
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
return err(CellError::Ref);
@@ -1978,3 +2056,54 @@ pub fn is_known_function(n: &str) -> bool {
#[allow(dead_code)]
fn unused(_: RefKind) {}
#[cfg(test)]
mod fuel_tests {
use super::*;
use gridcraft_model::Workbook;
struct Empty {
wb: Workbook,
}
impl Host for Empty {
fn workbook(&self) -> &Workbook {
&self.wb
}
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
Value::Empty
}
fn now_serial(&self) -> f64 {
0.0
}
fn random(&mut self) -> f64 {
0.0
}
}
fn eval_with(fuel: u64, src: &str) -> Value {
let mut host = Empty { wb: Workbook::default() };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
ev.fuel = fuel;
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
}
#[test]
fn lambda_calls_spend_fuel_and_run_out_quietly() {
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
// here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape,
// which happens well before the budget does.)
let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
assert_eq!(r, Value::number(4.0), "{r:?}");
// One charge short: the nest bottoms out in #CALC! instead of running unbounded.
let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
// One charge short of a single application: #CALC! with a zero budget.
let r = eval_with(0, "=LAMBDA(x,x+1)(2)");
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
let r = eval_with(1, "=LAMBDA(x,x+1)(2)");
assert_eq!(r, Value::number(3.0));
// A flat helper map keeps working (3 of 3 invokes inside the budget).
let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))");
assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}");
}
}
+140 -2
View File
@@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use gridcraft_model::{CalcMode, Workbook};
use crate::eval::{Area, Evaluator, Host, precedents};
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
pub type Key = (usize, CellRef);
@@ -432,6 +432,10 @@ struct PassHost<'a> {
tainted: HashSet<Key>,
/// Whether the formula being evaluated has read a previous value.
taint: bool,
/// `stack_position()` where the outermost formula being calculated started.
stack_top: usize,
/// Whether an evaluation ran past the stack budget since the innermost formula started.
out_of_stack: bool,
}
impl<'a> PassHost<'a> {
@@ -462,6 +466,8 @@ impl<'a> PassHost<'a> {
members: HashSet::default(),
tainted: HashSet::default(),
taint: false,
stack_top: 0,
out_of_stack: false,
}
}
}
@@ -580,6 +586,9 @@ impl PassHost<'_> {
self.suspend = Some(k);
return Value::Error(CellError::Calc);
}
if self.depth == 0 {
self.stack_top = stack_position();
}
self.in_progress.insert(k);
self.depth += 1;
let outer_taint = std::mem::replace(&mut self.taint, false);
@@ -587,10 +596,24 @@ impl PassHost<'_> {
self.stack.push(k);
}
let expr = std::sync::Arc::clone(expr);
let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false);
let v = {
let mut ev = Evaluator::new(self, k.0, k.1);
ev.value(&expr)
};
let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack);
if out_of_stack && self.depth > 1 && self.suspend.is_none() {
// It ran out of the stack left below the formulas that read it: it is calculated
// from the top instead, as a formula needed at `MAX_NESTED` levels deep is.
self.suspend = Some(k);
self.depth -= 1;
self.in_progress.remove(&k);
if self.iterate {
self.stack.pop();
}
self.taint = outer_taint;
return Value::Error(CellError::Calc);
}
if self.suspend.is_some() {
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
self.depth -= 1;
@@ -745,6 +768,12 @@ impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
fn stack_top(&self) -> Option<usize> {
(self.depth > 1).then_some(self.stack_top)
}
fn stack_exhausted(&mut self) {
self.out_of_stack = true;
}
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
let k = (sheet, anchor);
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
@@ -913,6 +942,7 @@ impl Calc {
/// Rebuilds the graph from scratch.
pub fn rebuild(&mut self, wb: &Workbook) {
self.graph = Graph::default();
let mut circ = Vec::new();
for (si, sheet) in wb.sheets.iter().enumerate() {
for (c, cell) in sheet.cells.iter() {
if let Some(f) = &cell.formula
@@ -920,10 +950,106 @@ impl Calc {
{
let (areas, dynamic) = precedents(wb, si, &e);
self.graph.insert((si, c), Node { areas, dynamic });
if cell.value == Value::Error(CellError::Circ) {
circ.push((si, c));
}
}
}
}
self.graph.settle();
self.circular = if wb.calc.iterative { Vec::new() } else { self.loops_among(&circ) };
}
/// The circular references of a rebuilt workbook (after undo, redo or a rollback, which put
/// back cells without calculating them), from the formulas whose cached value is `#CIRC!`:
/// the listed cells still on a loop, and one cell of each loop none of them is on.
fn loops_among(&self, circ: &[Key]) -> Vec<Key> {
if circ.is_empty() {
return Vec::new();
}
let index: HashMap<Key, usize> = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect();
// Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way.
let mut deps = Vec::new();
let edges: Vec<Vec<usize>> = circ
.iter()
.map(|k| {
deps.clear();
self.graph.dependents(k.0, k.1, &mut deps);
let mut e: Vec<usize> = deps.iter().filter_map(|d| index.get(d).copied()).collect();
e.sort_unstable();
e.dedup();
e
})
.collect();
// Tarjan's strongly connected components, iterative.
let n = circ.len();
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
let (mut next, mut comps) = (0usize, 0usize);
for root in 0..n {
if num[root] != usize::MAX {
continue;
}
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
num[root] = next;
low[root] = next;
next += 1;
stack.push(root);
on_stack[root] = true;
while let Some(top) = call.last_mut() {
let v = top.0;
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
top.1 += 1;
if num[w] == usize::MAX {
num[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
call.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(num[w]);
}
continue;
}
call.pop();
if let Some(&(u, _)) = call.last() {
low[u] = low[u].min(low[v]);
}
if low[v] == num[v] {
while let Some(w) = stack.pop() {
on_stack[w] = false;
comp[w] = comps;
if w == v {
break;
}
}
comps += 1;
}
}
}
let mut size = vec![0usize; comps];
for &c in &comp {
if let Some(s) = size.get_mut(c) {
*s += 1;
}
}
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
let mut out: Vec<Key> =
self.circular.iter().copied().filter(|k| index.get(k).is_some_and(|&i| on_loop(i) || self.graph.dynamic.contains(k))).collect();
let mut covered: HashSet<usize> = out.iter().filter_map(|k| index.get(k)).filter_map(|&i| comp.get(i).copied()).collect();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| circ.get(i).map(|(s, c)| (*s, c.row, c.col)));
for i in order {
if let (Some(&c), Some(&k)) = (comp.get(i), circ.get(i))
&& on_loop(i)
&& covered.insert(c)
{
out.push(k);
}
}
out
}
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
@@ -1103,7 +1229,12 @@ impl Calc {
}
let tw = prof_now();
self.last_recalc_cells += results.len();
self.circular = cycles;
// The loops this round finds replace the ones listed for the formulas it evaluated.
if !self.circular.is_empty() {
let evaluated: HashSet<Key> = dirty.iter().copied().collect();
self.circular.retain(|k| !evaluated.contains(k));
}
self.circular.extend(cycles);
// Write back.
let mut spill_changes: Vec<Key> = Vec::new();
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
@@ -1216,6 +1347,13 @@ impl Calc {
}
dirty = self.dirty_closure(wb, &spill_changes);
}
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
// Iterative calculation reports none.
if wb.calc.iterative {
self.circular.clear();
} else {
self.circular.retain(|k| self.graph.nodes.contains_key(k));
}
}
}
+506
View File
@@ -191,6 +191,81 @@ fn index_area_number_overflow() {
}
}
/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a
/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value.
#[test]
fn index_with_one_index() {
let mut t = T::new();
for (i, v) in [10, 20, 30].iter().enumerate() {
t.set(&format!("A{}", i + 1), &v.to_string());
}
let mut k = 1;
for r in 5..=7 {
for col in ["A", "B", "C"] {
t.set(&format!("{col}{r}"), &k.to_string());
k += 1;
}
}
for (row, (formula, expected)) in [
// Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value.
("=INDEX({10;20;30},2)", 20.0),
("=TYPE(INDEX({10;20;30},2))", 64.0),
("=TYPE(INDEX(SEQUENCE(3),2))", 64.0),
("=TYPE(INDEX({10;20;30},2,1))", 1.0),
("=TYPE(INDEX({10,20,30},2))", 1.0),
("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0),
("=SUM(INDEX({10;20;30},2))", 20.0),
// 1-D references with one index are single-cell references.
("=TYPE(INDEX(A1:A3,2))", 1.0),
("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0),
("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0),
// A 2-D array with one index is the row.
("=SUM(INDEX({1,2;3,4},2))", 7.0),
("=SUM(INDEX({1,2;3,4},0))", 10.0),
// A 2-D reference with an empty (not omitted) column argument is the row.
("=SUM(INDEX(A5:C7,2,))", 15.0),
("=SUM(INDEX(A5:C7,2,,1))", 15.0),
("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0),
("=INDEX(A5:C7,2,2)", 5.0),
// A 2-D reference with one index is #REF!, and the error carries through.
("=TYPE(INDEX(A5:C7,2))", 16.0),
("=ISREF(INDEX(A5:C7,2))", 0.0),
]
.iter()
.enumerate()
{
let at = format!("J{}", row * 4 + 1);
t.set(&at, formula);
let got = match t.get(&at) {
Value::Bool(b) => f64::from(u8::from(b)),
v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")),
};
assert_eq!(got, *expected, "{formula}");
}
for formula in [
"=INDEX(A5:C7,2)",
"=INDEX(A5:C7,0)",
"=INDEX(A5:C7,1.9)",
"=INDEX(A5:C7,)",
"=INDEX(A5:B6,1)",
"=INDEX(A5:C7,2)+1",
"=SUM(INDEX(A5:C7,{1;2}))",
"=ROWS(INDEX(INDEX(A5:C7,2),3))",
"=SUM(A5:INDEX(A5:C7,2))",
"=SUM(INDEX(A5:C7,2):C7)",
] {
t.set("L1", formula);
assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}");
}
t.set("L1", "=INDEX(A5:C7,{1;2})");
assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]);
t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")");
assert_eq!(t.get("L1"), Value::from("e"));
// A bad position is still #VALUE!.
t.set("L1", "=INDEX(A5:C7,\"x\")");
assert_eq!(t.get("L1"), Value::Error(CellError::Value));
}
#[test]
fn errors_and_cycles() {
let mut t = T::new();
@@ -309,6 +384,32 @@ fn circular_references_without_iterative_calculation() {
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
/// The list of circular references keeps the loops that a recalculation of other cells does not
/// reach, and drops a loop once a constant is typed over one of its cells.
#[test]
fn circular_references_stay_listed_until_the_loop_is_removed() {
let mut t = T::new();
t.set("A1", "=A1+1");
t.set("B1", "=B2");
t.set("B2", "=B1");
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
t.set("D4", "7");
t.set("D5", "=D4*2");
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
t.set("A1", "5");
assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
t.set("B2", "3");
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
// In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it.
t.wb.calc.mode = gridcraft_model::CalcMode::Manual;
t.set("C1", "=C1+1");
assert_eq!(t.calc.circular, vec![(0, c("C1"))]);
t.set("C1", "1");
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
}
#[test]
fn date_text_coerces_in_the_workbook_date_system() {
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
@@ -329,6 +430,149 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
/// significant digits. Every expectation was observed in Excel 365 with these fixtures
/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
#[test]
fn lookups_compare_numbers_exactly() {
let mut t = T::new();
for (at, input) in [
("A1", "=0.1+0.2"),
("A2", "0.3"),
("A3", "=100+1E-14"),
("A4", "100"),
("A5", "=1E15+0.5"),
("A6", "1E15"),
// Ascending 0.1, 0.3+e, 0.5 and the same descending.
("B1", "0.1"),
("B2", "=0.1+0.2"),
("B3", "0.5"),
("C1", "0.5"),
("C2", "=0.1+0.2"),
("C3", "0.1"),
// 0.3 next to 0.3+e, ascending and descending.
("D1", "0.3"),
("D2", "=0.1+0.2"),
("D3", "0.7"),
("E1", "0.7"),
("E2", "=0.1+0.2"),
("E3", "0.3"),
("G1", "=0.1+0.2"),
("H1", "x"),
("G2", "row2"),
// The bisection probes 0.3 first and must not run on into 0.3+e.
("J1", "0.1"),
("J2", "0.2"),
("J3", "0.3"),
("J4", "=0.1+0.2"),
("J5", "0.4"),
("K1", "a"),
("K2", "b"),
("K3", "c"),
("K4", "d"),
("K5", "e"),
] {
t.set(at, input);
}
let e = 0.1 + 0.2;
let n = Value::Number;
let s = |x: &str| Value::from(x);
let na = Value::Error(CellError::NA);
let cases: Vec<(&str, Value)> = vec![
// `=` and friends stay at 15 digits.
("=A1=A2", Value::Bool(true)),
("=A3=A4", Value::Bool(true)),
("=A5=A6", Value::Bool(true)),
("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
// Exact match.
("=MATCH(A1,A2:A2,0)", na.clone()),
("=MATCH(A2,A1:A1,0)", na.clone()),
("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
("=XMATCH(A1,A2:A2)", na.clone()),
("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
("=MATCH(0.3,A1:A2,0)", n(2.0)),
("=MATCH(A1,A1:A2,0)", n(1.0)),
("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
("=MATCH(100,A3:A3,0)", na.clone()),
("=MATCH(1E15,A5:A5,0)", na.clone()),
("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
// Approximate match against a single entry.
("=MATCH(0.3,A1:A1,1)", na.clone()),
("=MATCH(0.3,A1:A1,-1)", n(1.0)),
("=LOOKUP(0.3,A1:A1)", na.clone()),
("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
("=XMATCH(0.3,A1:A1,-1)", na.clone()),
("=XMATCH(0.3,A1:A1,1)", n(1.0)),
("=MATCH(100,A3:A3,1)", na.clone()),
("=MATCH(A3,A4:A4,1)", n(1.0)),
// Approximate match in sorted lists.
("=MATCH(0.3,B1:B3,1)", n(1.0)),
("=MATCH(A1,B1:B3,1)", n(2.0)),
("=MATCH(0.3,C1:C3,-1)", n(2.0)),
("=MATCH(0.3,D1:D3,1)", n(1.0)),
("=MATCH(A1,D1:D3,1)", n(2.0)),
("=MATCH(0.3,E1:E3,-1)", n(3.0)),
("=MATCH(A1,E1:E3,-1)", n(2.0)),
("=MATCH(0.3,J1:J5,1)", n(3.0)),
("=MATCH(A1,J1:J5,1)", n(4.0)),
("=MATCH(0.3,J1:J5,0)", n(3.0)),
("=MATCH(A1,J1:J5,0)", n(4.0)),
("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
// XLOOKUP / XMATCH match and search modes.
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
("=XMATCH(0.3,J1:J5,1)", n(3.0)),
("=XMATCH(A1,J1:J5,-1)", n(4.0)),
("=XMATCH(0.3,B1:B3,1)", n(2.0)),
("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
// The criteria functions keep 15 digits.
("=COUNTIF(A1:A1,0.3)", n(1.0)),
("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
("=COUNTIF(A2:A2,A1)", n(1.0)),
("=COUNTIFS(A1:A1,0.3)", n(1.0)),
("=SUMIF(A1:A1,0.3)", n(e)),
("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
("=AVERAGEIF(A1:A1,0.3)", n(e)),
("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
("=COUNTIF(A3:A3,\">100\")", n(0.0)),
("=COUNTIF(J1:J5,0.3)", n(2.0)),
("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
];
let mut wrong = Vec::new();
for (formula, expected) in cases {
let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
if got != expected {
wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
#[test]
fn lambda_helpers_check_shapes_and_parameters() {
let mut t = T::new();
@@ -352,6 +596,39 @@ fn lambda_helpers_check_shapes_and_parameters() {
assert_eq!(t.num("B7"), 2.0);
}
#[test]
fn lambda_calls_spend_one_work_budget_per_cell() {
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
let mut t = T::new();
t.set(
"A1",
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
);
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
assert_eq!(t.num("A1"), 45.0);
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
t.set("A1", "'$(1,234)");
t.set("B1", "=A1+0");
t.set("C1", "=VALUE(A1)");
t.set("D1", "=--\"$ (5)\"");
t.set("E1", "=\"(5)%\"*100");
t.set("F1", "=\"(-5)\"+0");
t.set("G1", "$(1,234)");
assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0));
assert_eq!(t.get("F1"), Value::Error(CellError::Value));
assert_eq!(t.num("G1"), -1234.0);
}
#[test]
fn operators() {
let mut t = T::new();
@@ -774,6 +1051,100 @@ fn lambda_argument_count() {
assert_eq!(t.get("A9"), Value::Bool(true));
}
#[test]
fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
// 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
let handle = std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(|| {
let mut t = T::new();
t.wb.names.push(gridcraft_model::DefinedName {
name: "LOOP".into(),
scope: None,
formula: "LAMBDA(n,LOOP(n))".into(),
comment: String::new(),
hidden: false,
});
t.calc.rebuild(&t.wb);
let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
t.set("A1", endless);
t.set("A2", "=LOOP(1)");
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
})
.expect("spawn");
let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
assert_eq!(endless, Value::Error(CellError::Num));
assert_eq!(endless_name, Value::Error(CellError::Num));
assert_eq!(through_map, Value::Error(CellError::Num));
assert_eq!(evaluated, Value::Error(CellError::Num));
assert_eq!(countdown, Value::Number(131.0));
}
#[test]
fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
let run = |stack: usize| {
std::thread::Builder::new()
.stack_size(stack)
.spawn(|| {
let mut wb = Workbook::new();
wb.names.push(gridcraft_model::DefinedName {
name: "CNT".into(),
scope: None,
formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
comment: String::new(),
hidden: false,
});
// F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
[tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
})
.expect("spawn")
.join()
.expect("recursion must not overflow the stack")
};
let small = run(1 << 20);
assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
assert_eq!(run(64 << 20), small);
}
#[test]
fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() {
let run = |stack: usize| {
std::thread::Builder::new()
.stack_size(stack)
.spawn(|| {
// Row-major order calculates A1 first, so each cell below is calculated on demand
// at the deepest call of the one above it.
let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))");
let mut wb = Workbook::new();
let sheet = wb.sheet_mut(0).expect("sheet");
for (at, f) in [
("A1", countdown("A2")),
("A2", countdown("A3")),
("A3", countdown("0")),
("B1", countdown("B2")),
("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()),
] {
sheet.cells.set(c(at), Cell::formula(Formula::new(&f)));
}
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default())
})
.expect("spawn")
.join()
.expect("recursion must not overflow the stack")
};
let small = run(1 << 20);
let num = Value::Error(CellError::Num);
assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]);
assert_eq!(run(64 << 20), small);
}
#[test]
fn names_and_sheets() {
let mut t = T::new();
@@ -1513,6 +1884,91 @@ fn offset_extreme_arguments_return_ref() {
}
}
#[test]
fn analysis_toolpak_value_params_reject_multi_cell_references() {
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
// than one cell, however the reference is formed, and lift array constants and computed arrays.
let mut t = T::new();
for (a, v) in [
("A1", "45000"),
("A2", "45031"),
("A3", "45061"),
("B1", "1"),
("B2", "2"),
("B3", "3"),
("C2", "7"),
("F1", "1"),
("F2", "2"),
("F3", "11"),
] {
t.set(a, v);
}
t.wb.names.push(gridcraft_model::DefinedName {
name: "Dates".into(),
scope: None,
formula: "Sheet1!$A$1:$A$3".into(),
comment: String::new(),
hidden: false,
});
let value = Value::Error(CellError::Value);
for f in [
"=EOMONTH(A1:A3,1)",
"=EOMONTH(A1,B1:B3)",
"=EDATE(A1:A3,1)",
"=SUMPRODUCT(EOMONTH(A1:A3,0))",
"=EOMONTH(INDEX(A1:A3,0,1),0)",
"=EOMONTH(OFFSET(A1,0,0,3,1),0)",
"=EOMONTH(IF(TRUE,A1:A3),0)",
"=EOMONTH(CHOOSE(1,A1:A3),0)",
"=LET(x,A1:A3,EOMONTH(x,0))",
"=EOMONTH(Dates,0)",
"=EOMONTH((A1,A2),0)",
"=EOMONTH(Z:Z,0)",
"=EOMONTH(A1:INDEX(A1:A3,3),0)",
"=WORKDAY(A1:A3,1)",
"=NETWORKDAYS(A1,A3:A4)",
"=YEARFRAC(A1,A3,B1:B2)",
"=WEEKNUM(A1,B1:B3)",
"=COUPDAYS(A1:A3,46000,2,0)",
"=DISC(A1,46000,98,100,B1:B3)",
"=BIN2DEC(B1:B3)",
"=ISEVEN(B1:B3)",
] {
t.set("H5", f);
assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}");
t.set("H5", "");
}
// One-cell references, intersections, @ and the range parameters are fine.
for (f, want) in [
("=EOMONTH(A1:A1,0)", 45016.0),
("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0),
("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0),
("=EOMONTH(A1:A3 A2:C2,0)", 45046.0),
("=EOMONTH(@A1:A3,0)", 45046.0),
("=WORKDAY(A1,1,A1:A3)", 45001.0),
("=XNPV(0,B1:B3,A1:A3)", 6.0),
] {
t.set("H2", f);
assert_eq!(t.num("H2"), want, "{f}");
t.set("H2", "");
}
// Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend.
for (f, below, right) in [
("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty),
("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)),
("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)),
("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty),
("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty),
("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty),
// The rejected reference adds no shape: a 2x1 array of #VALUE!.
("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty),
] {
t.set("H5", f);
assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}");
t.set("H5", "");
}
}
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
@@ -1768,3 +2224,53 @@ fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
t.set("E11", "=ROWS(D1#)");
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
}
#[test]
fn text_before_after_empty_instance_defaults_to_one() {
let mut t = T::new();
t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)");
assert_eq!(t.get("A1"), Value::Text("Little ".into()));
t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")");
assert_eq!(t.get("A2"), Value::Text("b".into()));
t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")");
assert_eq!(t.get("A3"), Value::Text("nf".into()));
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
}
#[test]
fn loan_schedule_residue_keeps_excels_sign() {
// A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest,
// each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment
// Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes
// from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead.
let mut t = T::new();
t.set("B1", "4653300");
t.set("B2", "1");
t.set("B3", "0.141");
let col = |i: u8| char::from(b'A' + i).to_string();
t.set("C8", "=$B$1*($B$3/12)");
t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8");
t.set("D8", "=($B1-C7)*($B$3/12)");
t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8");
for i in 4..16 {
let (c, p) = (col(i), col(i - 1));
t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")"));
t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")"));
}
t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")");
// Excel's values (Value2) for the same synthetic inputs.
// The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference
// moves it by as much as its own size: only its sign is checked, and the 13th period must exist.
let positive = |cell: &str| match t.get(cell) {
Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"),
other => panic!("{cell}: expected a small positive number, got {other:?}"),
};
positive("N4");
positive("O8");
match t.get("O7") {
Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"),
other => panic!("O7: expected a number, got {other:?}"),
}
assert_eq!(t.get("P7"), Value::Text("".into()));
}
+30 -3
View File
@@ -160,21 +160,28 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
segs.push((*a, *b));
}
if dash {
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
// long dashed lines multiplies: together they would eagerly allocate megabytes of
// geometry even when nothing of it reaches the canvas, so the total is capped too and
// the line is drawn solid once the budget is gone.
let (on, off) = (width * 4.0, width * 3.0);
let mut out = Vec::new();
let mut budget = 65_536usize;
for (a, b) in segs {
if budget == 0 {
return vec![vec![pts.to_vec()]];
}
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = (dx * dx + dy * dy).sqrt();
if !(len.is_finite() && len > 0.0) {
continue;
}
let mut t = 0.0;
let mut n = 0;
while t < len && n < 10_000 {
while t < len && budget > 0 {
let e = (t + on).min(len);
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
t += on + off;
n += 1;
budget -= 1;
}
}
segs = out;
@@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
}
cv.buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dashed_geometry_stays_within_the_dash_budget() {
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
let prims: Vec<Prim> =
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
assert_eq!(img.len(), 2 * 2 * 4);
// And the geometry itself is bounded even for one huge dashed polyline.
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
}
}
+30 -14
View File
@@ -868,6 +868,7 @@ enum Fam {
LineStack,
Area,
AreaStack,
Area100,
}
fn family(k: ChartKind) -> Fam {
@@ -879,10 +880,16 @@ fn family(k: ChartKind) -> Fam {
LineStacked => Fam::LineStack,
Area => Fam::Area,
AreaStacked => Fam::AreaStack,
AreaStacked100 => Fam::Area100,
_ => Fam::Col,
}
}
/// Families stacked to 100%: each category's values are shares of its total.
fn is_100(f: Fam) -> bool {
matches!(f, Fam::Col100 | Fam::Area100)
}
fn is_col(f: Fam) -> bool {
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
}
@@ -900,25 +907,38 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
// Totals for 100% stacking, per axis.
let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]];
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
for (i, s) in data.series.iter().enumerate() {
if fams.get(i) == Some(&Fam::Col100) {
let a = sec.get(i).copied().unwrap_or(false) as usize;
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
if is_100(fam) {
let key = (sec.get(i).copied().unwrap_or(false), fam);
let p = match totals.iter().position(|(k, _)| *k == key) {
Some(p) => p,
None => {
totals.push((key, vec![0.0; ncat]));
totals.len() - 1
}
};
for (j, v) in s.values.iter().take(ncat).enumerate() {
if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) {
if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) {
*t += v.abs();
}
}
}
}
// A value's share of its category's total, for the 100% families.
let share = |key: (bool, Fam), j: usize, v: f64| {
let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0);
if t > 0.0 { v / t } else { 0.0 }
};
// Stack accumulators keyed by (axis, family): positive and negative running sums.
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let a = sec.get(i).copied().unwrap_or(false);
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack);
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);
let key = (a, fam);
let si = if stacked {
match stacks.iter().position(|(k, _, _)| *k == key) {
@@ -936,12 +956,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
let v = s.values.get(j).copied().flatten();
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
let v = if fam == Fam::Col100 {
let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0);
if t > 0.0 { v / t } else { 0.0 }
} else {
v
};
let v = if fam == Fam::Col100 { share(key, j, v) } else { v };
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
let base = *acc;
*acc += v;
@@ -949,6 +964,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
}),
(Some((_, pos, _)), _) => {
let v = v.unwrap_or(0.0);
let v = if fam == Fam::Area100 { share(key, j, v) } else { v };
pos.get_mut(j).map(|acc| {
let base = *acc;
*acc = clean(*acc + v);
@@ -970,7 +986,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
continue;
}
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100));
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
let range = value_range(members.iter().flat_map(|i| {
let line = fams.get(*i).is_some_and(|f| is_line(*f));
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
@@ -1044,7 +1060,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let want = match pass {
0 => matches!(fam, Fam::Area | Fam::AreaStack),
0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100),
1 => is_col(fam),
_ => is_line(fam),
};
+42 -2
View File
@@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
const ALL_KINDS: [ChartKind; 25] = [
const ALL_KINDS: [ChartKind; 26] = [
ChartKind::ColumnClustered,
ChartKind::ColumnStacked,
ChartKind::ColumnStacked100,
@@ -19,6 +19,7 @@ const ALL_KINDS: [ChartKind; 25] = [
ChartKind::Doughnut,
ChartKind::Area,
ChartKind::AreaStacked,
ChartKind::AreaStacked100,
ChartKind::Scatter,
ChartKind::ScatterLines,
ChartKind::Bubble,
@@ -1073,7 +1074,7 @@ fn pie_points_follow_workbook_theme() {
#[test]
fn area_charts_draw_source_value_labels_only_when_enabled() {
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] {
let d = sample(kind);
let mut c = chart(kind);
c.data_labels = false;
@@ -1097,6 +1098,45 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
}
}
#[test]
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100),
series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100),
],
};
let mut c = chart(ChartKind::AreaStacked100);
c.data_labels = false;
let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let t = texts(&p);
assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}");
assert!(!t.contains(&"40"), "{t:?}");
let polys: Vec<&Vec<[f32; 2]>> = p
.iter()
.filter_map(|p| match p {
Prim::Polygon { pts, .. } => Some(pts),
_ => None,
})
.collect();
let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") };
// Each polygon is its top edge left to right, then its base right to left.
let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN);
let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN);
let (y0, y100) = (base(north, 0), top(south, 0));
assert!(y0 > y100, "{y0} {y100}");
let frac = |y: f32| (y0 - y) / (y0 - y100);
for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] {
assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j)));
assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j)));
}
// A category whose total is zero stays on the baseline.
assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2)));
}
#[test]
fn two_level_categories_resolve_and_draw() {
let years = ["2023", "", "", "2024", "", ""];
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
+15
View File
@@ -350,6 +350,21 @@ mod tests {
assert_eq!(split_sheet("A1"), (None, "A1"));
}
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
/// reference.
#[test]
fn split_sheet_leaves_device_names_inert_in_the_body() {
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
let (sheet, rest) = split_sheet(s);
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
let _ = sheet;
}
// A plain single-letter sheet is still split, not mistaken for a body.
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
}
#[test]
fn letters_roundtrip() {
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
+111 -24
View File
@@ -28,15 +28,22 @@ pub struct DateTime {
pub weekday: u32,
}
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
///
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = m as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
.and_then(|date| {
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
}
}
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
/// [`days_from_civil`]).
///
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
/// 1970-01-01 is `num_days_from_ce` 719_163.
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
use chrono::Datelike;
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
}
// Unreachable: `rest + 719_163` is always a valid CE day.
None => (1970, 1, 1),
}
}
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
const UNIX_1904_01_01: i64 = -24107;
/// Whether `y` is a Gregorian leap year, for every `i32` year.
pub fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
let y = i64::from(y).rem_euclid(400) as i32;
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
}
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
/// `i32` year.
pub fn days_in_month(y: i32, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
// The length of a month is the day before the first of the next month. Month lengths repeat
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
if !(1..=12).contains(&m) {
return 0;
}
let y = i64::from(y).rem_euclid(400) as i32;
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
_ => 0,
}
}
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
assert!(is_leap(400_000)); // divisible by 400
assert!(!is_leap(300_100)); // century not divisible by 400
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
assert_eq!(days_in_month(400_000, 2), 29);
assert_eq!(days_in_month(300_100, 2), 28);
assert_eq!(days_in_month(400_000, 1), 31);
assert_eq!(days_in_month(400_000, 4), 30);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2023, 0), 0);
assert_eq!(days_in_month(2023, 13), 0);
// December at i32::MAX does not overflow and still reports 31 days.
assert_eq!(days_in_month(i32::MAX, 12), 31);
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
}
#[test]
fn civil_conversions_exact_beyond_chrono_range() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
// the epoch; values round-trip through the proleptic Gregorian converters.
for (y, m, d, z) in [
(262_144i32, 1u32, 1u32, 95_026_602i64),
(300_000, 1, 1, 108_853_222),
(400_000, 2, 29, 145_377_531),
(-262_145, 3, 1, -96_465_964),
(-300_000, 6, 15, -110_292_112),
] {
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
}
// The extremes must not overflow or panic (i128 intermediates).
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
}
#[test]
fn serials_unchanged_through_cycle_conversion() {
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(days_in_month_in(s, 1900, 2), 29);
let s4 = DateSystem::D1904;
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
}
#[test]
fn serial_from_unix_time() {
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
+176 -25
View File
@@ -54,23 +54,25 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
/// fractions kept as text are not flagged, as in Excel.
pub fn number_stored_as_text(s: &str) -> Option<f64> {
let t = s.trim();
match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n),
// The same parentheses typed entry reads, accounting forms included: `$(5)`, `(5)%`.
match parenthesized(t) {
Some(inner) if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None,
Some(inner) => parse_plain(&inner).map(|(n, _)| -n),
None => parse_plain(t).map(|(n, _)| n),
}
}
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
let mut t = t;
let mut neg = false;
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
t = inner.trim();
neg = !neg;
// Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`.
if let Some(inner) = parenthesized(t) {
// Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.
if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {
return None;
}
let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));
return r.map(|(n, f)| (-n, f));
}
let (n, f) = parse_unparenthesised(t, sys)?;
Some((if neg { -n } else { n }, f))
parse_unparenthesised(t, sys)
}
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
@@ -89,6 +91,22 @@ fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'stat
None
}
/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may
/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as
/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it.
fn parenthesized(t: &str) -> Option<String> {
let (currency, rest) = match t.strip_prefix('$') {
Some(r) => ("$", r.trim_start()),
None => ("", t),
};
let rest = rest.strip_prefix('(')?;
let (body, percent) = match rest.strip_suffix(')') {
Some(b) => (b, ""),
None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"),
};
Some(format!("{currency}{}{percent}", body.trim()))
}
/// Signs, currency, thousands separators, percent, exponent.
fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
let mut s = t;
@@ -113,7 +131,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
percent = true;
s = r.trim_end();
}
if s.is_empty() {
// Excel reads `$5` and `5%` but not `$5%`.
if s.is_empty() || currency && percent {
return None;
}
let has_sep = s.contains(',');
@@ -167,7 +186,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
}
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
/// finite number (#2163).
fn plain_number(s: &str, frac: bool) -> Option<f64> {
let (int, dec) = match s.split_once('.') {
Some((i, d)) if frac => (i, Some(d)),
@@ -177,7 +197,7 @@ fn plain_number(s: &str, frac: bool) -> Option<f64> {
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
return None;
}
s.parse().ok()
s.parse().ok().filter(|x: &f64| x.is_finite())
}
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
@@ -192,6 +212,9 @@ fn parse_fraction(t: &str) -> Option<f64> {
return None;
}
let v = w + a / b;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
@@ -348,10 +371,16 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
if let Some((d, f)) = parse_date(t, sys) {
return Some((d, Some(f)));
}
// date + time: split at the last space(s) before a time-looking tail.
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
// instead of reparsing every prefix for every space (one long text field quadratic).
for (i, _) in t.match_indices(' ') {
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
let Some(b) = t.get(i + 1..) else { continue };
let bytes = b.as_bytes();
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
continue;
}
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
return Some((d + tf, Some("m/d/yyyy h:mm")));
}
}
@@ -366,6 +395,31 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
/// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
#[test]
fn overlong_digits_stay_text() {
let big = "9".repeat(400);
for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
}
#[test]
fn overlong_time_fields_stay_text() {
// #2168: all-digit field lengths are gated before allocation/derivation,
// so hostile strings never spin the time-derivation machinery.
let big = "9".repeat(500);
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
assert_eq!(parse_number_text(&s), None, "{s}");
}
// Within the gate, times still work.
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
}
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
@@ -407,20 +461,107 @@ mod tests {
assert_eq!(p(".5").value, Value::Number(0.5));
}
/// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree.
#[test]
fn accounting_parentheses() {
let accepted: [(&str, f64, Option<&str>); 22] = [
("$(5)", -5.0, Some("\"$\"#,##0")),
("$ (5)", -5.0, Some("\"$\"#,##0")),
("$( 5 )", -5.0, Some("\"$\"#,##0")),
("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")),
("$(1,234)", -1234.0, Some("\"$\"#,##0")),
("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")),
("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")),
("$(.5)", -0.5, Some("\"$\"#,##0.00")),
("$(0)", 0.0, Some("\"$\"#,##0")),
(" $(5) ", -5.0, Some("\"$\"#,##0")),
("($5)", -5.0, Some("\"$\"#,##0")),
("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")),
("(5)", -5.0, None),
("( 5 )", -5.0, None),
("(1,234.5)", -1234.5, Some("#,##0.00")),
("(1e-3)", -0.001, Some("0.00E+00")),
("(5)%", -0.05, Some("0%")),
("( 5 ) %", -0.05, Some("0%")),
("(5%)", -0.05, Some("0%")),
("(5.)%", -0.05, Some("0.00%")),
("(1,234)%", -12.34, Some("0%")),
("(1 1/2)", -1.5, Some("# ?/?")),
];
for (s, n, f) in accepted {
assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}");
assert_eq!(parse_number_text(s), Some(n), "{s}");
}
for s in [
"$5%",
"$(5%)",
"$(5)%",
"($5%)",
"($5)%",
"$ ( 5 ) %",
"(5)%%",
"(5%)%",
"((5))",
"(-5)",
"(+5)",
"($-5)",
"($ -5)",
"($+5)",
"(-$5)",
"$(-5)",
"$ (-5)",
"$(+5)",
"-(5)",
"-$(5)",
"+$(5)",
"$+(5)",
"$$(5)",
"$ $(5)",
"$((5))",
"($(5))",
"(-1 1/2)",
"()",
"$()",
"(5)$",
"$(5)$",
"(5)(5)",
"$(5)0",
"(5)e3",
"(5)-",
"(5-)",
"$(5-)",
"$(5",
"$5)",
"(5",
"$(1,23)",
"$(,5)",
"(5e)",
"(1/2)",
"$(1/2)",
"(3/4/2026)",
"$(3/4/2026)",
"(10:30)",
"$(10:30)",
] {
assert_eq!(p(s).value, Value::text(s), "{s}");
assert_eq!(parse_number_text(s), None, "{s}");
}
}
#[test]
fn deeply_nested_parentheses_do_not_overflow() {
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
// #2180: coercion used to recurse once per parenthesis and overflow the stack. Excel reads
// one pair only, so nesting is text now, and it is rejected without recursing.
let depth = 100_000;
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
assert_eq!(parse_number_text(&s), Some(1.0));
assert_eq!(parse_number_text(&s), None);
assert_eq!(p(&s).value, Value::text(s.as_str()));
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
assert_eq!(parse_number_text(&odd), Some(-1.0));
assert_eq!(parse_number_text(&odd), None);
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
assert_eq!(parse_number_text(&unbalanced), None);
assert_eq!(p(&s).value, Value::Number(1.0));
// Existing results are unchanged.
// A single pair still reads.
assert_eq!(parse_number_text("(100)"), Some(-100.0));
assert_eq!(parse_number_text("((100))"), Some(100.0));
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
assert_eq!(parse_number_text("()"), None);
}
@@ -474,11 +615,21 @@ mod tests {
#[test]
fn numbers_stored_as_text() {
for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] {
for (text, n) in [
("123", 123.0),
(" 42 ", 42.0),
("-1,000.5", -1000.5),
("$3", 3.0),
("50%", 0.5),
("1E3", 1000.0),
("(100)", -100.0),
("$(5)", -5.0),
("(5)%", -0.05),
] {
assert_eq!(number_stored_as_text(text), Some(n), "{text}");
}
// Dates, times, fractions, words and blanks are not flagged.
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] {
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))", "(-5)", "$(5)%", "(1/2)"] {
assert_eq!(number_stored_as_text(text), None, "{text}");
}
}
+22 -1
View File
@@ -267,6 +267,12 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
}
}
/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so
/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls
/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic
/// when a paste holds many bare `&`).
const ENTITY_WINDOW: usize = 12;
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
fn decode(raw: &str) -> String {
@@ -275,7 +281,9 @@ fn decode(raw: &str) -> String {
while let Some(amp) = rest.find('&') {
out.push_str(rest.get(..amp).unwrap_or_default());
rest = rest.get(amp..).unwrap_or_default();
let semi = rest.get(..12).unwrap_or(rest).find(';');
// `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte
// boundary used to fall back to the complete suffix, re-scanning per '&'.
let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.find(';'));
let entity = semi.and_then(|e| rest.get(1..e));
let ch = entity.and_then(|e| match e {
"amp" => Some('&'),
@@ -382,6 +390,19 @@ mod tests {
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn entities_recognized_only_within_the_twelve_byte_window() {
// The window never scans the rest of the text for a semicolon: a bare '&' followed by
// long plain text stays a bare '&', and one '&' after another is linear.
assert_eq!(decode("a&amplifier; b"), "a&amplifier; b");
assert_eq!(decode("&ampampampampAMP;"), "&ampampampampAMP;");
// Inside the window the usual forms still decode, and a trailing bare '&' too.
assert_eq!(decode("&#38;"), "&");
assert_eq!(decode("&#x266A;"), "\u{266A}");
assert_eq!(decode("a&amp;b&nbsp;c"), "a&b\u{a0}c");
assert_eq!(decode("amp&"), "amp&");
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
+210 -8
View File
@@ -106,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
show_levels
),
cmd!(
"data.outlineSettings",
"Outline Settings",
["Data", "Outline"],
None,
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
has_doc,
outline_settings
),
cmd!(
"data.subtotal",
"Subtotal",
@@ -652,6 +661,24 @@ fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
}
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
return;
}
let Some(af) = sh.autofilter.as_ref() else { return };
let r = af.range;
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
return;
}
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
if let Some(af) = sh.autofilter.as_mut() {
af.range = grown;
}
}
/// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
@@ -1311,6 +1338,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let b = b.min(a.saturating_add(1_048_575));
for i in a..=b {
@@ -1321,9 +1349,10 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
}
}
// A summary line whose group is gone has nothing left to collapse.
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
if !on
&& !outline::groups(map).iter().any(|g| g.end == b)
&& let Some(e) = map.get_mut(&b.saturating_add(1))
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
{
e.collapsed = false;
}
@@ -1341,8 +1370,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let all = outline::groups(map);
let all = outline::groups_with(map, before);
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
@@ -1351,13 +1381,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
};
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
if chosen.is_empty() {
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
}
for g in chosen {
// Inner groups collapsed on their own (their summary marked), before this one changes.
let inner: Vec<outline::OutlineGroup> = all
.iter()
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
.copied()
.collect();
@@ -1366,6 +1396,9 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
}
}
if !g.has_summary() {
continue;
}
if hide {
map.entry(g.summary()).or_default().collapsed = true;
} else if let Some(e) = map.get_mut(&g.summary()) {
@@ -1401,15 +1434,16 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
let Some(n) = n else { continue };
let groups = outline::groups(map);
let groups = outline::groups_with(map, before);
for e in map.values_mut().filter(|e| e.outline > 0) {
set_hidden(e, e.outline >= n);
}
// Inner groups come first, so a summary line shared with the group around it ends up
// as that outer group says.
for g in groups {
for g in groups.into_iter().filter(|g| g.has_summary()) {
map.entry(g.summary()).or_default().collapsed = g.level >= n;
}
}
@@ -1418,6 +1452,53 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
let si = target_sheet(s, p)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
let Some(on) = bool_param(p, key) else { continue };
let was_before = outline::summary_before(sh, rows);
if rows {
sh.summary_below = on;
} else {
sh.summary_right = on;
}
if was_before != on {
continue;
}
let map = if rows { &mut sh.rows } else { &mut sh.cols };
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
.into_iter()
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
.collect();
// A mark on a line that nested groups share moves with the outermost of them whose
// detail is all hidden, or with the outermost when none is.
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
for g in &marked {
let key = (!g.collapsed, g.level);
let best = owner.entry(g.summary()).or_insert(key);
*best = (*best).min(key);
}
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
for g in &marked {
if let Some(e) = map.get_mut(&g.summary()) {
e.collapsed = false;
}
}
for g in marked {
let g = outline::OutlineGroup { summary_before: !on, ..g };
if g.has_summary() {
map.entry(g.summary()).or_default().collapsed = true;
}
}
}
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
})
}
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.subtotal")?;
let header = bool_param(p, "header").unwrap_or(true);
@@ -1458,6 +1539,11 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
start = row;
}
}
// The totals go below each group, so the outline puts summary rows below their detail (the
// Subtotal dialog's "Summary below data"), in the same undo step.
if !sh.summary_below {
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
}
// Insert a total row after each group, bottom-up so indices stay valid.
let mut inserted = 0;
for (g0, g1, name) in groups.iter().rev() {
@@ -1522,6 +1608,28 @@ mod tests {
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
}
#[test]
fn subtotal_puts_summary_rows_below_their_detail() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.summary_below, "the totals sit below their groups");
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
assert!(!hidden(&s, 3), "East Total stays visible");
// One undo takes the subtotals and the setting back.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
assert!(!sh.summary_below);
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
@@ -1554,6 +1662,33 @@ mod tests {
}
}
#[test]
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
assert_eq!(range(&s), Some("A1:B4".to_string()));
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A row typed after a blank row does not grow it.
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A cell outside the filter's columns does not either.
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// Empty text is a blank entry, not a value.
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A formula and Ctrl+Enter (range.fill) are entries too.
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
assert_eq!(range(&s), Some("A1:B6".to_string()));
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
assert_eq!(range(&s), Some("A1:B7".to_string()));
}
#[test]
fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new();
@@ -1726,6 +1861,73 @@ mod tests {
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
}
#[test]
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
let mut s = nested_rows();
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// Hide Detail on the summary row collapses the group below it.
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
// Ungrouping the outer group clears its mark on the row above.
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
}
#[test]
fn changing_the_summary_direction_moves_collapsed_marks() {
let mut s = nested_rows();
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
assert!(col_marked(&s, 1), "columns keep their direction");
// One undo step per change; no params leaves the settings as they are.
s.execute("edit.undo", json!({})).unwrap();
assert!(row_state(&s, 3..=3)[0].1);
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
// Nested groups that end on one row share its mark: it moves to the outer group only.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
// Hide and Show Detail keep it collapsed.
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
// A group on the first row has no row above it to mark.
let mut s = Session::new();
s.new_workbook();
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
}
#[test]
fn column_sort_rejects_row_key_zero_without_panicking() {
let mut s = Session::new();
+346 -19
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
has_doc,
range_set_values
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
has_doc,
auto_fill
),
@@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at);
}
}
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
Ok(Json::Null)
})?;
@@ -299,6 +300,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
// A string is run through typed input by default, which reads it with en-US rules: a caller
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
// are unaffected either way.
let parse = match p.get("parse") {
None | Some(Json::Null) => true,
Some(Json::Bool(b)) => *b,
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
};
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
@@ -321,6 +331,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
// Text as given: no date or number reading, and a leading `'` is part of the
// text rather than the marker that makes it text. A formula is still a
// formula, which is what the ticket asks for and what `=` means in a cell.
Json::String(text) if !parse && !text.starts_with('=') => {
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
// Line breaks still turn on Wrap Text, as typed input does.
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
}
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
@@ -330,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at);
}
}
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
count += 1;
}
@@ -370,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
cx.sheet_mut(sheet)?.set_cell(at, cell);
let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
}
}
@@ -553,26 +577,153 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
Ok(json!({"selection": d.selection.a1()}))
}
/// How many references the Go To dialog keeps for going back to.
const GO_TO_RECENT: usize = 10;
/// The most areas one Go To selects.
const GO_TO_MAX_AREAS: usize = 2000;
/// How deep a name that refers to other names is followed.
const GO_TO_NAME_DEPTH: usize = 8;
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
// A defined name or table?
let d = s.doc()?;
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
let sheet = d.wb.active_sheet;
if let Some(n) = d.wb.name(reference, sheet) {
n.formula.clone()
} else if let Some((si, ti)) = d.wb.table(reference) {
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
} else {
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
if target.is_some_and(|t| t != sheet) {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
} else {
reference.to_string()
target = Some(sheet);
named |= by_name;
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
}
}
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
if bool_param(p, "record") == Some(true) && !named {
let d = s.doc_mut()?;
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
d.go_to_recent.insert(0, entry);
d.go_to_recent.truncate(GO_TO_RECENT);
}
Ok(out)
}
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
let mut chars = text.char_indices();
std::iter::from_fn(move || {
if done {
return None;
}
for (i, c) in chars.by_ref() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
let part = text.get(start..i).unwrap_or("").trim();
start = i + 1;
return Some(part);
}
_ => {}
}
}
done = true;
Some(text.get(start..).unwrap_or("").trim())
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
let resolved = resolved.replace('$', "");
selection_set(s, &json!({"range": resolved}))
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && n.name.eq_ignore_ascii_case(body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
/// do not refer to a range are left out.
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> = wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| n.name.to_ascii_lowercase()).collect();
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&n.name.to_ascii_lowercase()) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
})
.collect();
out.sort_by_key(|n| n.to_lowercase());
out.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
out
}
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
@@ -717,7 +868,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
@@ -1240,6 +1391,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
Ok(())
})?;
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
// formats (cell styles and merged areas, no conditional formats), column widths and notes
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
// except for column widths and notes, which write no cells at all.
let passive =
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
if !same_doc && !passive {
let d = s.doc_mut()?;
d.trust = d.trust.receive(clip.trust);
}
if cut_move {
s.clipboard = None;
}
@@ -1428,6 +1589,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => crate::fill::FillMode::Series,
};
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
@@ -1752,6 +1917,119 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
mod tests {
use super::*;
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
/// formula and a hidden name.
fn go_to_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
s.execute("selection.set", json!({"sheet": 0})).unwrap();
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
};
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
define(&mut s, "Rate", "=0.07", "Workbook");
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
wb.names.push(gridcraft_model::DefinedName {
name: "Secret".into(),
scope: None,
formula: "Sheet1!$Z$9".into(),
comment: String::new(),
hidden: true,
});
s
}
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
/// that are not ranges, and hidden names, are not listed.
#[test]
fn go_to_lists_the_named_ranges_of_every_sheet() {
let mut s = go_to_workbook();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
assert_eq!(list["recent"], json!([]), "{list}");
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
assert_eq!(out["selection"], "C3", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
// A name of several areas selects all of them.
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
}
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
/// each with a comma ... Excel will highlight all the ranges."
#[test]
fn go_to_selects_every_reference_and_name_in_a_list() {
let mut s = go_to_workbook();
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
assert_eq!(out["selection"], "A1,B2", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
}
}
#[test]
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
assert_eq!(parts.next(), Some("A1"));
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
assert_eq!(parts.next(), None);
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
let mut s = go_to_workbook();
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
assert!(err.to_string().contains("at most"), "{err}");
let bad = format!("Total{}", ",".repeat(1_000_000));
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
}
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
#[test]
fn go_to_keeps_the_references_entered_newest_first() {
let mut s = go_to_workbook();
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
// Names are listed already; a call without `record` (the Name Box) is not kept.
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
// Going back from Sheet2 to a recent reference returns to its sheet.
s.execute("selection.set", json!({"sheet": 1})).unwrap();
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
for i in 0..GO_TO_RECENT + 2 {
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
}
#[test]
fn cell_set_takes_numbers_for_input_and_value() {
// #697: a JSON number for `input` used to clear the cell instead of entering it.
@@ -1769,6 +2047,55 @@ mod tests {
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
}
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
#[test]
fn set_values_can_store_strings_as_the_text_they_are() {
let mut s = Session::new();
s.new_workbook();
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
// With `parse: false` each string is the text it was given, and a formula is still a
// formula.
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
for (i, want) in strings.iter().enumerate() {
let cell = format!("{}3", (b'A' + i as u8) as char);
let got = get(&mut s, &cell);
if *want == "=1+1" {
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
assert_eq!(got["formula"], "=1+1", "{got}");
} else {
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
}
}
// Numbers and booleans are untouched either way.
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
assert_eq!(get(&mut s, "B5")["value"], json!(true));
assert_eq!(get(&mut s, "C5")["text"], "");
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
// A line break still turns on Wrap Text.
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
}
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
+23 -2
View File
@@ -212,6 +212,15 @@ pub fn specs() -> Vec<CommandSpec> {
trust_scripts
),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
cmd!(
noundo "automate.stopAction",
"Stop Action",
[],
None,
"{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began",
has_doc,
stop_action
),
// Cell controls, themes, borders, styles
cmd!(
"insert.checkbox",
@@ -800,7 +809,7 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?.clone();
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust);
copy.selection = d.selection.clone();
copy.scripts_trusted = d.scripts_trusted;
let i = s.add_document(copy);
@@ -1025,6 +1034,11 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
if id.starts_with("file.") || id.starts_with("link.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
// Scripts are scoped to their workbook: a step that switches the active window would
// point every later step at another open document, so those are refused too.
if id.starts_with("window.") {
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
}
if params.get("path").is_some() {
return Some(format!("scripts can't pass a file `path` to `{id}`"));
}
@@ -1087,6 +1101,13 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"ran": n}))
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
}
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
@@ -1249,7 +1270,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}"))));
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
ok()
}
+3 -2
View File
@@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
Some(name) => {
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
let title = crate::sample::title(name);
s.add_document(DocState::new(wb, None, title))
s.add_document(DocState::new(wb, None, title, crate::Trust::Local))
}
None => s.new_workbook(),
};
@@ -83,7 +83,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
{
s.close_document(0);
}
let mut doc = DocState::new(wb, path, title);
// Someone else may have made the file: its active content asks before it runs.
let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile);
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
doc.content_kind = Some(crate::io::FileKind::Xlsx);
}
+16
View File
@@ -233,10 +233,23 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
}
edit(s, |cx| {
// Formatting on a protected sheet keeps the cell's permission flags: a format call
// that flips `locked` from the format permission would authorize content edits that
// the password should still gate, so those flags may not change (Excel keeps that
// checkbox disabled while the sheet is protected).
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
macro_rules! guard_flags {
($old:expr, $new:expr) => {
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
return Err(EngineError::Other(FLAG_GUARD.into()));
}
};
}
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
for r in &ranges {
let full_rows = r.is_full_rows();
@@ -251,6 +264,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
info.and_then(|i| i.style).unwrap_or_default()
};
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
guard_flags!(cur, new);
let sh = cx.sheet_mut(sheet)?;
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
map.entry(line).or_default().style = Some(new);
@@ -260,6 +274,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
for (c, st) in &cells {
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
guard_flags!(*st, new);
cx.sheet_mut(sheet)?.set_style(*c, new);
}
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
@@ -276,6 +291,7 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
if new != st {
guard_flags!(st, new);
cx.sheet_mut(sheet)?.set_style(c, new);
}
}
+250 -7
View File
@@ -1,7 +1,7 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::*;
@@ -32,21 +32,38 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Name…",
["Formulas", "Defined Names"],
None,
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc,
define_name
),
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
cmd!(
"formulas.deleteName",
"Delete Name",
[],
None,
"{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc,
delete_name
),
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
cmd!(
"formulas.createFromSelection",
"Create from Selection",
["Formulas", "Defined Names"],
Some("Cmd+Shift+F3"),
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
has_doc,
create_from_selection
),
cmd!(
"formulas.pasteName",
"Paste Names…",
["Formulas", "Defined Names"],
Some("F3"),
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
has_doc,
paste_name
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
@@ -268,6 +285,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
if !valid_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() {
refuse_stash_name(&s.doc()?.wb, p, n)?;
}
let d = s.doc()?;
let scope = match str_param(p, "scope") {
None | Some("Workbook") => None,
@@ -374,8 +394,29 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
}
}
/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]):
/// defining, renaming or deleting one would replace or remove part of an item (a stored macro,
/// script or view) and leave the rest behind. `force: true` lets the call go ahead.
/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope
/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and
/// the name commands may change it.
fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool {
// The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not.
stash::holds_item(wb, name)
}
fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> {
if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) {
return Err(EngineError::Other(format!(
"'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway."
)));
}
Ok(())
}
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
refuse_stash_name(&s.doc()?.wb, p, &name)?;
let scope = match str_param(p, "scope") {
None => None,
Some("Workbook") => Some(None),
@@ -461,7 +502,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
// reported.
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
// A label that names one of GridCraft's own hidden data names would replace part of an item
// (see `refuse_stash_name`).
let force = bool_param(p, "force").unwrap_or(false);
let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n);
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n)));
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
let n = made.len();
edit(s, |cx| {
@@ -474,6 +519,61 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet;
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
for n in shown(false) {
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
out.push((n.name.clone(), format!("={}", n.formula)));
}
}
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
out
}
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
if !bool_param(p, "list").unwrap_or(false) {
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
};
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
}
return Ok(json!({"names": listed}));
}
if names.is_empty() {
return Err(EngineError::Other("This workbook has no names to paste.".into()));
}
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet;
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
let c = CellRef::new(row, col);
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(c);
// The second column holds the refers-to text as a text value, with no formula.
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
cx.touch(sheet, c);
}
}
Ok(json!({"names": listed, "range": range.a1()}))
})
}
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
@@ -628,8 +728,12 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
}
}
}
let circ: Vec<String> =
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
let circ: Vec<String> = d
.calc
.circular
.iter()
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
.collect();
Ok(json!({"errors": out, "circular": circ}))
}
@@ -742,6 +846,57 @@ mod tests {
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
}
#[test]
fn the_name_commands_leave_gridcraft_data_alone() {
// Defining, renaming or deleting a name of a stored item's shape would replace or remove
// one of its chunks and leave the rest behind: refused unless `force` is true.
let mut s = Session::new();
s.new_workbook();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let text = "m".repeat(9_000);
stash::set(wb, "macro_1", &text).unwrap();
wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true });
// A legacy item is read whether or not it is hidden, so a visible one is data too.
wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false });
let names = |s: &Session| s.doc().unwrap().wb.names.clone();
let before = names(&s);
assert!(before.len() >= 3, "the item takes two chunks");
for params in [
json!({"name": "_gc_macro_1_0", "refersTo": "=1"}),
json!({"name": "_SC_Watch", "refersTo": "=1"}),
json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}),
json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}),
json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}),
] {
let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string();
assert!(err.contains("force"), "{params}: {err}");
}
for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] {
let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string();
assert!(err.contains("force"), "{name}: {err}");
}
s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap();
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap();
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"])));
let after = names(&s);
assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::<Vec<_>>(), before);
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()));
// A visible name of the shape is the person's own (the stash never reads it): the name
// commands change and remove it without `force`, as Name Manager cannot pass one.
let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false };
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone());
s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap();
s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own);
s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap();
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched");
// Ordinary names and forced calls go ahead.
s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap();
s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap();
s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap();
assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1"));
}
#[test]
fn arabic_defined_names_count_characters_not_bytes() {
let mut s = Session::new();
@@ -790,4 +945,92 @@ mod tests {
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
/// `Tax`, and a name of another sheet.
fn named_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
wb.names.push(name("TAX", Some(0), "0.25", false));
wb.names.push(name("Other", Some(1), "1", false));
s
}
fn text_at(s: &Session, cell: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
#[test]
fn paste_names_lists_the_names_the_active_sheet_can_use() {
let mut s = named_workbook();
let r = s.execute("formulas.pasteName", json!({})).unwrap();
assert_eq!(
r["names"],
json!([
{"name": "rate", "refersTo": "=0.07"},
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
{"name": "TAX", "refersTo": "=0.25"},
])
);
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
}
#[test]
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
let mut s = named_workbook();
s.take_ui_requests();
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
}
#[test]
fn paste_list_writes_each_name_and_what_it_refers_to() {
let mut s = named_workbook();
s.execute("selection.set", json!({"range": "D2"})).unwrap();
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
assert_eq!(r["range"], json!("D2:E4"));
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
for (cell, text) in rows {
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
}
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
s.execute("edit.undo", json!({})).unwrap();
for (cell, _) in rows {
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
}
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
}
#[test]
fn paste_list_needs_names_and_room() {
let mut s = named_workbook();
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
let mut empty = Session::new();
empty.new_workbook();
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
}
}
+19 -3
View File
@@ -26,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Format as Table",
["Home", "Styles"],
None,
"{range?, style: \"TableStyleMedium2\", header?}",
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
has_doc,
insert_table
format_as_table
),
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
s, p, "totals"
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
// Without a range, the active area decides (as in insert_table), not the first one.
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
let sheet = target_sheet(s, p)?;
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
match inside {
Some((table, range)) => {
table_style(s, &with(p, "table", json!(table)))?;
Ok(json!({"table": table, "range": range.a1()}))
}
None => insert_table(s, p),
}
}
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
if let Some(n) = str_param(p, "table") {
@@ -567,6 +582,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
("pie", _) => ChartKind::Pie,
("doughnut", _) => ChartKind::Doughnut,
("area", "stacked") => ChartKind::AreaStacked,
("area", "stacked100") => ChartKind::AreaStacked100,
("area", _) => ChartKind::Area,
("scatter", "lines") => ChartKind::ScatterLines,
("scatter", _) => ChartKind::Scatter,
+8 -5
View File
@@ -7,11 +7,11 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect),
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history),
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
]
}
@@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
"title": d.display_title(),
"path": d.path,
"dirty": d.is_dirty(),
"trust": d.trust,
"activeSheet": wb.active_sheet,
"sheets": sheets,
"selection": d.selection.a1(),
@@ -148,9 +149,11 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result<Json> {
fn history(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
Ok(
json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>()}),
)
Ok(json!({
"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
"redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::<Vec<_>>(),
"grouping": s.in_group(d.uid),
}))
}
fn list_commands(s: &mut Session, p: &Json) -> Result<Json> {
+32 -1
View File
@@ -19,6 +19,7 @@ pub mod sheet;
pub mod slicer;
pub mod solver;
pub mod spelling;
pub mod stash;
pub mod view;
pub mod whatif;
@@ -96,12 +97,19 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
not_grouping(s)?;
if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
}
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
not_grouping(s)?;
if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
}
/// Undo and Redo wait while an undo group is open on the active document (an action that spans
/// several calls is still running).
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
@@ -179,6 +187,23 @@ pub fn register_test_command(id: &'static str, run: Run) {
test_commands().push(spec);
}
/// Test-only: an undoable command, to exercise how `execute` records its step.
#[cfg(test)]
pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id,
label: id,
menu: &[],
shortcut: None,
params: "",
enabled: |_| Ok(()),
run,
journal: false,
undoable: true,
}));
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() {
@@ -527,9 +552,15 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
return;
}
let mut need: f32 = default;
// The cell loop must not rescan every merge per cell: the merges touching this row are
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
// a sheet with many merges and many saved columns would otherwise scan all of them per
// cell, rows × merges × columns).
let row_merges: Vec<gridcraft_core::RangeRef> =
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
continue;
}
let st = wb.styles.get(cell.style);
+337 -10
View File
@@ -179,6 +179,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
change_source
),
cmd!(
"pivot.showDetails",
"Show Details",
[],
None,
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).",
has_doc,
show_details
),
cmd!(
"pivot.options",
"PivotTable Options",
["PivotTable Analyze", "PivotTable"],
None,
"{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}",
has_doc,
options
),
cmd!(
"pivot.delete",
"Delete PivotTable",
@@ -654,16 +672,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let (si, anchor) = match target {
Some(t) => t,
None => {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
let at = insert_sheet_before_active(cx);
new_sheet_at = Some(at);
(at, CellRef::new(2, 0))
}
@@ -726,6 +735,135 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
Ok(res)
}
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
at
}
/// Show Details (double-click a value cell): the source records behind it, as values with their
/// column's number format, under the source headers on a new sheet.
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
.iter()
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
// With the option off, a report that is out of date or a source that cannot be read is
// reported as the option; only a cell that is not a value cell keeps its own message.
if !pt.enable_show_details && !matches!(found, Ok(None)) {
return Err(other(SHOW_DETAILS_OFF));
}
let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
if d.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let count = records.len();
let mut new_sheet_at: Option<usize> = None;
let res = edit(s, |cx| {
let si = insert_sheet_before_active(cx);
new_sheet_at = Some(si);
let base = cx.wb.styles.get(StyleId::default()).clone();
let fmts: Vec<StyleId> = src
.formats
.iter()
.map(|f| {
let mut st = base.clone();
st.num_fmt = NumFmt::new(f);
cx.wb.styles.intern(st)
})
.collect();
// Columns wide enough for their text, as the PivotTable report's are (dates would show
// as ####); the first rows are enough to judge a column.
let font = base.font.clone();
let widths: Vec<f32> = src
.headers
.iter()
.enumerate()
.map(|(c, h)| {
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
let widest = texts
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
(widest + 14.0).min(320.0)
})
.collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths.into_iter().enumerate() {
if w > sh.col_width(c as u32) {
sh.cols.entry(c as u32).or_default().size = Some(w);
}
}
for (c, h) in src.headers.iter().enumerate() {
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
for (c, v) in rec.iter().enumerate() {
let style = fmts.get(c).copied().unwrap_or_default();
if v.is_empty() && style == StyleId::default() {
continue;
}
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
}
}
let name = sh.name.clone();
cx.wb.active_sheet = si;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
})?;
if let Some(at) = new_sheet_at
&& let Ok(d) = s.doc_mut()
{
d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(res)
}
/// The message Show Details gives while Enable show details is off.
const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable.";
/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not
/// refreshed. Without params it returns the current setting.
fn options(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
let enable = bool_param(p, "enableShowDetails");
if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() {
return Err(bad("pivot.options", "`enableShowDetails` must be true or false"));
}
let current = |s: &Session| -> Result<Json> {
let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
Ok(json!({"enableShowDetails": pt.enable_show_details}))
};
let Some(on) = enable else { return current(s) };
if current(s)?["enableShowDetails"] == json!(on) {
return current(s);
}
edit(s, |cx| {
let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?;
pt.enable_show_details = on;
Ok(json!({"enableShowDetails": on}))
})
}
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "table") {
@@ -763,6 +901,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
"enableShowDetails": pt.enable_show_details,
})
}
@@ -2236,6 +2375,8 @@ mod tests {
"data.refreshAll",
"pivot.changeSource",
"pivot.delete",
"pivot.showDetails",
"pivot.options",
];
let hostile = [
json!({}),
@@ -2363,4 +2504,190 @@ mod tests {
assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
/// The active sheet's first five columns as shown, down to the first blank row.
fn sheet_rows(s: &mut Session) -> Vec<Vec<String>> {
let mut out = Vec::new();
for r in 0..30u32 {
let row: Vec<String> = (0..5)
.map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string())
.collect();
if row.iter().all(String::is_empty) {
break;
}
out.push(row);
}
out
}
#[test]
fn show_details_lists_a_value_cells_records_on_a_new_sheet() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 is East × Apples (100 + 60); the active cell is the default.
s.execute("selection.set", json!({"range": "B5"})).unwrap();
let r = s.execute("pivot.showDetails", json!({})).unwrap();
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
vec![
vec!["Region", "Product", "Date", "Units", "Sales"],
vec!["East", "Apples", "1/15/2026", "10", "100"],
vec!["East", "Apples", "9/9/2026", "6", "60"],
]
);
// The dates fit their column instead of showing ####.
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5));
// One undo step takes the sheet away.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(val(&s, "B5"), Value::Number(160.0));
}
#[test]
fn show_details_on_a_total_takes_every_record_it_adds_up() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
let records = |s: &mut Session, cell: &str| {
let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap();
s.execute("edit.undo", json!({})).unwrap();
n
};
assert_eq!(records(&mut s, "E5"), 5, "East's row total");
assert_eq!(records(&mut s, "C8"), 4, "Pears' column total");
assert_eq!(records(&mut s, "E8"), 12, "the grand total");
// A subtotal at the top of its group, in a two-level row axis.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(records(&mut s, "B4"), 5);
assert_eq!(text(&s, "A6"), "Pears");
assert_eq!(records(&mut s, "B6"), 2);
}
#[test]
fn show_details_leaves_out_the_records_the_report_filters_out() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap();
assert_eq!(r["records"], 2);
let rows = sheet_rows(&mut s);
assert_eq!(rows.len(), 3);
assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}");
}
#[test]
fn show_details_refuses_a_report_its_source_has_moved_on_from() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 still shows East × Apples (160), but the first East record is now Central's.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
assert_eq!(val(&s, "B5"), Value::Number(160.0));
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
// After a refresh East × Apples (now B6) has the one record left.
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(text(&s, "A6"), "East");
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1);
}
#[test]
fn show_details_refuses_a_report_that_would_now_be_shorter() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total.
s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap();
assert_eq!(text(&s, "A8"), "Grand Total");
let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn show_details_needs_a_value_cell() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] {
assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn enable_show_details_off_stops_show_details() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true);
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
assert!(!pt(&s).enable_show_details);
assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false);
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert_eq!(e, "Show Details is turned off for this PivotTable.");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
// A label still reports that it is not a value cell.
let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string();
assert!(!e.contains("turned off"), "{e}");
// Setting the same value again adds no undo step.
let after = s.doc().unwrap().revision;
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
assert_eq!(s.doc().unwrap().revision, after);
assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err());
// One undo turns the option back on.
s.execute("edit.undo", json!({})).unwrap();
assert!(pt(&s).enable_show_details);
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2);
}
#[test]
fn enable_show_details_off_is_reported_before_an_out_of_date_report() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
// The source changes after the report was written, so the report is out of date.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert!(e.contains("turned off") && !e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn detail_cells_are_the_cells_show_details_takes() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for layout in 0..2 {
if layout == 1 {
// A two-level row axis, with subtotals at the top of each group.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
}
let d = s.doc().unwrap();
let si = d.wb.active_sheet;
let pt = &d.wb.active().unwrap().pivots[0];
let r = pt.last_range.unwrap();
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
let mut values = 0;
for row in 0..r.height() {
for col in 0..r.width() {
let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap();
assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})");
values += usize::from(one.is_some());
}
}
assert!(values > 4, "layout {layout} has value cells");
}
// An out-of-date report answers as Show Details does.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
let d = s.doc().unwrap();
let pt = &d.wb.active().unwrap().pivots[0];
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet).unwrap_err().contains("out of date"));
}
}
+174 -93
View File
@@ -139,8 +139,26 @@ impl SheetLayout {
let [l, r, t, b, ..] = self.margins;
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
}
/// Left and right margins, in points, that header and footer sections line up with: the
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
/// most a quarter of the page width.
pub(crate) fn hf_sides(&self) -> (f32, f32) {
if self.settings.hf_align_with_margins {
(self.margins[0], self.margins[1])
} else {
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
(side, side)
}
}
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
pub(crate) fn hf_text_scale(&self) -> f32 {
if self.settings.hf_scale_with_doc { self.scale } else { 1.0 }
}
}
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
/// Options shared by the print commands.
#[derive(Clone, Debug, Default)]
pub struct PrintOptions {
@@ -784,22 +802,27 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
out.push(std::mem::take(&mut line));
line = word.to_string();
}
// Break a single over-long word by characters.
// Break a single over-long word by characters. The cut that fits is found by binary
// search (each try measures one candidate prefix), so an over-long word costs
// O(n log n) measurements instead of re-measuring the whole cut per character for
// every emitted line (`a` × 100k pasted text would be cubic worst case).
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let mut cut = String::new();
let mut rest = String::new();
for ch in line.chars() {
if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() {
cut.push(ch);
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
rest.push(ch);
hi = mid - 1;
}
}
if rest.is_empty() {
if lo == chars.len() {
break;
}
out.push(cut);
line = rest;
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
}
if out.len() > 2000 {
return out;
@@ -1306,7 +1329,16 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
}
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) {
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
/// picture again, but the decode happens once).
fn draw_objects(
pg: &mut Page,
wb: &Workbook,
si: usize,
sh: &Sheet,
b: &Block,
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
) {
let block = b.range();
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
@@ -1316,6 +1348,42 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k);
pg.save();
pg.clip_rect(bx, by, bw, bh);
draw_sparklines(pg, wb, si, sh, b, block);
for (imi, im) in sh.images.iter().enumerate() {
let r = anchor_rect(sh, &im.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
// About 150 dpi is plenty for print.
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(x, y, w, h, px, *pw, *ph);
}
}
for shp in &sh.shapes {
let r = anchor_rect(sh, &shp.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
draw_shape(pg, wb, shp, b, (x, y, w, h));
}
for ch in &sh.charts {
let r = anchor_rect(sh, &ch.anchor);
if !visible(r) {
continue;
}
let (x, y, _, _) = place(r);
let data = gridcraft_chart::resolve(wb, si, ch);
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
}
pg.restore();
}
fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) {
for sp in &sh.sparklines {
if !block.contains(sp.cell) {
continue;
@@ -1330,90 +1398,64 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block)
let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
}
for im in &sh.images {
let r = anchor_rect(sh, &im.anchor);
if !visible(r) {
continue;
}
fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) {
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
})
.collect();
pg.polygon(&pts, fill, line);
}
let (x, y, w, h) = place(r);
// About 150 dpi is plenty for print.
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) {
pg.image_rgba(x, y, w, h, &px, pw, ph);
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
ShapeKind::Line | ShapeKind::Arrow => {
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
if shp.kind == ShapeKind::Arrow {
let ang = h.atan2(w);
let al = 8.0 * b.k;
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
}
}
_ => {
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
}
}
for shp in &sh.shapes {
let r = anchor_rect(sh, &shp.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
})
.collect();
pg.polygon(&pts, fill, line);
}
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
ShapeKind::Line | ShapeKind::Arrow => {
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
if shp.kind == ShapeKind::Arrow {
let ang = h.atan2(w);
let al = 8.0 * b.k;
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
}
}
_ => {
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
}
}
if !shp.text.is_empty() {
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
}
if shp.text.is_empty() {
return;
}
for ch in &sh.charts {
let r = anchor_rect(sh, &ch.anchor);
if !visible(r) {
continue;
}
let (x, y, _, _) = place(r);
let data = gridcraft_chart::resolve(wb, si, ch);
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
pg.restore();
}
fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) {
@@ -1452,7 +1494,9 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
}
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
let [l, r, _, _, hm, fm] = lay.margins;
let [_, _, _, _, hm, fm] = lay.margins;
let (l, r) = lay.hf_sides();
let text_scale = lay.hf_text_scale();
let pw = lay.page_w;
let ph = lay.page_h;
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
@@ -1464,7 +1508,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::
if sec.text.trim().is_empty() {
continue;
}
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0);
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale;
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
let n = lines.len() as f32;
@@ -1528,6 +1572,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut page_no = 0usize;
let mut pending_links = Vec::new();
let mut printed_blocks = Vec::new();
// Decoded pictures are reused across the pages their anchor covers: a floating picture
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -1564,7 +1611,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
y += seg_size(sh, *rs, true) * k;
}
if let Some(b) = &body {
draw_objects(pg, wb, lay.sheet, sh, b);
draw_objects(pg, wb, lay.sheet, sh, b, &mut pics);
}
if lay.settings.headings {
let fill = [0xF2, 0xF2, 0xF2];
@@ -1994,6 +2041,40 @@ mod tests {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
/// The x position and font size of the first `(text) Tj` in a PDF.
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
let s = String::from_utf8_lossy(pdf);
let before = &s[..s.find(&format!("({text}) Tj"))?];
let tf = before.rfind(" Tf")?;
let size = before[..tf].rsplit(' ').next()?.parse().ok()?;
let tm = before.rfind(" Tm")?;
let mut nums = before[..tm].rsplit([' ', '\n']);
let _y = nums.next()?;
let x = nums.next()?.parse().ok()?;
Some((x, size))
}
#[test]
fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap();
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap();
// Both on (the default): the text prints at half size, from the 1.5-inch left margin.
let (pdf, _) = pdf_of(&mut s, json!({}));
assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0)));
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0));
let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap();
assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false)));
// Both off: full size, 0.7 inch from the paper's left edge.
let (pdf, _) = pdf_of(&mut s, json!({}));
assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0)));
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0));
s.execute("edit.undo", json!({})).unwrap();
let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print;
assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins);
}
#[test]
fn pdf_prints_cjk_text_instead_of_question_marks() {
let mut s = s();
+10 -4
View File
@@ -401,8 +401,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
// sheet is active: both carry the command's own `sheet` parameter on.
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
@@ -586,11 +588,15 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
let revision = d.revision;
let before = (d.wb.clone(), d.selection.clone());
// Inside an undo group the nested steps add no undo entries of their own.
let nested_entries = !s.in_group(d.uid);
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
let d = s.doc_mut()?;
// The nested steps collapse into this command's single undo step.
for _ in 0..d.revision.saturating_sub(revision) {
d.undo.pop();
if nested_entries {
for _ in 0..d.revision.saturating_sub(revision) {
d.undo.pop();
}
}
match result {
Ok((final_range, out)) => {
+606
View File
@@ -0,0 +1,606 @@
//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx
//! file.
//!
//! An item `key` is stored as names `_gc_<key>_0`, `_gc_<key>_1`, …, each a formula of string
//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a
//! formula to 8,192, so a long value is split over as many names as it needs and the file stays
//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
//! format later, and a write of the same key replaces it.
//!
//! XML can't carry every character (controls other than tab, line feed and carriage return, and
//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the
//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits
//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode
//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on
//! ordinary text. Legacy `_sc_` items are read as they are.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use gridcraft_model::{DefinedName, Workbook};
use serde_json::Value as Json;
use crate::{EngineError, Result};
const PREFIX: &str = "_gc_";
const LEGACY_PREFIX: &str = "_sc_";
/// Characters (UTF-16 units, as Excel counts them) in one string literal.
const LITERAL_UNITS: usize = 255;
/// Characters in one name's formula.
const FORMULA_UNITS: usize = 8_192;
/// The longest key: `_gc_<key>_<k>` stays well inside Excel's 255-character names.
const MAX_KEY: usize = 200;
/// The longest item: 4,194,304 characters.
pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024;
/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character
/// takes seven (the three-byte marker and four digits).
const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7;
/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter.
pub const ESCAPE: char = '\u{FDD0}';
/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A
/// file can carry any number of hidden names; the rest are not listed.
pub const MAX_ITEMS: usize = 10_000;
/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its
/// legacy `_sc_` name.
#[derive(Default)]
struct Parts<'a> {
chunks: BTreeMap<u64, &'a str>,
legacy: Option<&'a str>,
}
impl<'a> Parts<'a> {
fn add(&mut self, chunk: Option<u64>, formula: &'a str) {
match chunk {
Some(i) => {
self.chunks.entry(i).or_insert(formula);
}
None => self.legacy = self.legacy.or(Some(formula)),
}
}
/// Whether the names make an item: a later chunk on its own does not.
fn is_item(&self) -> bool {
self.chunks.contains_key(&0) || self.legacy.is_some()
}
}
#[cfg(test)]
thread_local! {
/// Names [`group`] has looked at on this thread, so a test can bound the work of a listing.
static NAME_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names
/// that hold them, in one pass over the names.
fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap<String, Parts<'a>> {
let prefix = prefix.to_ascii_lowercase();
let mut items: BTreeMap<String, Parts<'a>> = BTreeMap::new();
for n in wb.names.iter() {
#[cfg(test)]
NAME_VISITS.with(|v| v.set(v.get().saturating_add(1)));
let Some((k, chunk)) = item_of(n) else { continue };
let k = k.to_ascii_lowercase();
if k.starts_with(&prefix) {
items.entry(k).or_default().add(chunk, &n.formula);
}
}
items
}
/// The item text stored under `key`, or `None` when there is none (or it is over the size limit).
pub fn get(wb: &Workbook, key: &str) -> Option<String> {
let mut parts = Parts::default();
for n in wb.names.iter() {
if let Some((k, chunk)) = item_of(n)
&& k.eq_ignore_ascii_case(key)
{
parts.add(chunk, &n.formula);
}
}
assemble(&parts)
}
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit.
pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap<String, String> {
group(wb, prefix)
.into_iter()
.filter(|(_, parts)| parts.is_item())
.take(MAX_ITEMS)
.filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text)))
.collect()
}
/// The text of the item held by `parts`, as [`get`] reads it.
fn assemble(parts: &Parts) -> Option<String> {
let chunks = &parts.chunks;
let mut out = String::new();
if !chunks.contains_key(&0) {
out = string_literals(parts.legacy?);
} else {
// Chunks run 0, 1, 2, …; anything after a gap isn't part of the item.
for (want, (i, f)) in chunks.iter().enumerate() {
if *i != want as u64 {
break;
}
out.push_str(&string_literals(f));
if out.len() > MAX_STORED_BYTES {
return None;
}
}
out = unescape(&out);
}
(out.chars().count() <= MAX_ITEM_CHARS).then_some(out)
}
/// Whether `c` is stored escaped: XML can't carry it, or it is the marker.
fn needs_escape(c: char) -> bool {
c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four
/// hex digits naming a character (a hand-made name) stays as it is.
fn unescape(s: &str) -> String {
if !s.contains(ESCAPE) {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find(ESCAPE) {
out.push_str(rest.get(..at).unwrap_or(""));
let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or("");
let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok());
match code.and_then(char::from_u32) {
Some(c) => {
out.push(c);
rest = after.get(4..).unwrap_or("");
}
None => {
out.push(ESCAPE);
rest = after;
}
}
}
out.push_str(rest);
out
}
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
check_key(key)?;
if text.chars().count() > MAX_ITEM_CHARS {
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
}
let formulas = chunk_formulas(text);
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
// it with the same name would be the one an xlsx file drops. Refuse before changing anything,
// in one pass over the names (a file can carry many thousands).
let chunks = formulas.len() as u64;
let taken = |n: &&DefinedName| {
n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks))
};
if let Some(n) = wb.names.iter().find(taken) {
return Err(EngineError::Other(format!(
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
n.name
)));
}
remove(wb, key);
for (i, formula) in formulas.into_iter().enumerate() {
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
}
Ok(())
}
/// Removes the item stored under `key`.
pub fn remove(wb: &mut Workbook, key: &str) {
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key)));
}
/// The item under `key` as JSON (`None` when missing or not JSON).
pub fn get_json(wb: &Workbook, key: &str) -> Option<Json> {
serde_json::from_str(&get(wb, key)?).ok()
}
pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
set(wb, key, &v.to_string())
}
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
/// where a [`get`] per key would take one each.
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect()
}
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
/// `_gc_<key>_<k>` name, or a workbook-scope legacy `_sc_<key>` name, which is read whether or not
/// it is hidden. A visible `_gc_` name is the person's own.
pub fn holds_item(wb: &Workbook, name: &str) -> bool {
wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some())
}
/// The item a hidden name belongs to, lower-cased: `_gc_<key>_<k>` and `_sc_<key>` give `<key>`.
pub fn item_key(name: &str) -> Option<String> {
parse_name(name).map(|(k, _)| k.to_ascii_lowercase())
}
/// The item a name holds a part of: a hidden workbook-scope `_gc_<key>_<k>` name, or a legacy
/// `_sc_<key>` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is
/// neither read, nor listed, nor removed on write.
fn item_of(n: &DefinedName) -> Option<(&str, Option<u64>)> {
if n.scope.is_some() {
return None;
}
parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden)
}
/// `_gc_<key>_<k>` → (`key`, `Some(k)`); `_sc_<key>` → (`key`, `None`).
fn parse_name(name: &str) -> Option<(&str, Option<u64>)> {
if let Some(rest) = strip_prefix_ci(name, PREFIX) {
let (key, k) = rest.rsplit_once('_')?;
// Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not
// chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`.
let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0'));
if key.is_empty() || !canonical {
return None;
}
return k.parse().ok().map(|i| (key, Some(i)));
}
strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None))
}
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten()
}
fn check_key(key: &str) -> Result<()> {
let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.');
if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) }
}
/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula
/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]).
/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back.
pub fn chunk_formulas(text: &str) -> Vec<String> {
let mut formulas = Vec::new();
let (mut formula, mut units) = (String::new(), 0usize);
let (mut literal, mut lit_units) = (String::from("\""), 0usize);
let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| {
literal.push('"');
let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty());
if *units + cost > FORMULA_UNITS && !formula.is_empty() {
formulas.push(std::mem::take(formula));
*units = 0;
}
if !formula.is_empty() {
formula.push('&');
*units += 1;
}
*units += literal.encode_utf16().count();
formula.push_str(literal);
*literal = String::from("\"");
*lit_units = 0;
};
for ch in text.chars() {
// An escaped character is the marker and four digits, kept in one literal.
let escaped = needs_escape(ch);
let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() };
if lit_units + cost > LITERAL_UNITS {
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
}
lit_units += cost;
if escaped {
literal.push(ESCAPE);
let _ = write!(literal, "{:04X}", u32::from(ch));
} else {
if ch == '"' {
literal.push('"');
}
literal.push(ch);
}
}
if lit_units > 0 {
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
}
if !formula.is_empty() {
formulas.push(formula);
}
formulas
}
/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored).
pub fn string_literals(f: &str) -> String {
let mut out = String::new();
let mut it = f.chars().peekable();
let mut in_str = false;
while let Some(c) = it.next() {
if !in_str {
if c == '"' {
in_str = true;
}
continue;
}
if c == '"' {
if it.peek() == Some(&'"') {
it.next();
out.push('"');
} else {
in_str = false;
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn name(n: &str, formula: &str) -> DefinedName {
DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true }
}
fn excel_limits_hold(wb: &Workbook) {
for n in &wb.names {
assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len());
// Every literal (quotes undoubled) is at most 255 characters.
for lit in n.formula.split("\"&\"") {
let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\"");
assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len());
}
}
}
#[test]
fn long_values_split_over_names_and_come_back() {
let mut wb = Workbook::new();
// Quotes double when written; characters outside the BMP count twice.
let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect();
set(&mut wb, "big", &text).unwrap();
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
assert!(names.len() > 5, "{names:?}");
assert_eq!(names[0], "_gc_big_0");
assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none()));
excel_limits_hold(&wb);
assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str()));
assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive");
// Shorter again: the extra chunks go.
set(&mut wb, "big", "short").unwrap();
assert_eq!(wb.names.len(), 1);
assert_eq!(wb.names[0].formula, "\"short\"");
set(&mut wb, "big", "").unwrap();
assert!(wb.names.is_empty());
assert_eq!(get(&wb, "big"), None);
}
#[test]
fn chunks_and_literals_respect_the_boundaries() {
for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] {
let text = "q".repeat(len);
let fs = chunk_formulas(&text);
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text, "{len}");
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}");
}
// All quotes: each literal doubles in the formula.
let text = "\"".repeat(9_000);
let fs = chunk_formulas(&text);
assert_eq!(fs.iter().map(|f| string_literals(f)).collect::<String>(), text);
assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS));
}
#[test]
fn the_old_single_name_still_reads_and_is_replaced_on_write() {
let mut wb = Workbook::new();
wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\""));
wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\""));
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]})));
assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2])));
set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap();
let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]);
assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []})));
}
#[test]
fn hostile_names_read_safely() {
let mut wb = Workbook::new();
// A gap: chunk 2 without chunk 1.
wb.names.push(name("_gc_gap_0", "\"a\""));
wb.names.push(name("_gc_gap_2", "\"c\""));
assert_eq!(get(&wb, "gap").as_deref(), Some("a"));
// Only a later chunk: no item, not listed, and the legacy name is still read.
wb.names.push(name("_gc_orphan_2", "\"c\""));
assert_eq!(get(&wb, "orphan"), None);
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
wb.names.push(name("_sc_orphan", "\"old\""));
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
wb.names.push(name("_gc_mix_0", "\"ignored\""));
wb.names.push(name("_gc_mix_2", "\"c"));
assert_eq!(get(&wb, "mix").as_deref(), Some("abc"));
// Names that only look like chunks.
for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] {
wb.names.push(name(odd, "\"zzz\""));
}
assert_eq!(get(&wb, "x"), None);
assert_eq!(get(&wb, ""), None);
// Not JSON, or JSON nested deeper than the parser allows.
wb.names.push(name("_gc_notjson_0", "\"{\""));
assert_eq!(get_json(&wb, "notjson"), None);
let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000));
wb.names.push(name("_gc_deep_0", &deep));
assert_eq!(get_json(&wb, "deep"), None);
// Sheet-scoped names are someone else's.
wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") });
assert_eq!(get(&wb, "scoped"), None);
// Bad keys are refused.
for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] {
assert!(set(&mut wb, bad, "v").is_err(), "{bad}");
}
assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err());
}
#[test]
fn oversized_items_are_not_read() {
let mut wb = Workbook::new();
let chunk = format!("\"{}\"", "x".repeat(250));
let big = vec![chunk.as_str(); 32].join("&");
for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) {
wb.names.push(name(&format!("_gc_huge_{i}"), &big));
}
assert_eq!(get(&wb, "huge"), None);
}
#[test]
fn a_visible_name_of_the_item_shape_is_not_an_item() {
// Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and
// writing or removing the item leaves it alone.
let mut wb = Workbook::new();
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
wb.names.push(own.clone());
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
// keeps only one of them: refused, with the names as they were.
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
assert!(err.contains("_gc_watch_0"), "{err}");
assert_eq!(wb.names, vec![own.clone()]);
assert_eq!(get(&wb, "watch"), None);
set(&mut wb, "other", "theirs").unwrap();
assert_eq!(get(&wb, "other").as_deref(), Some("theirs"));
remove(&mut wb, "watch");
remove(&mut wb, "other");
assert_eq!(wb.names, vec![own]);
}
#[test]
fn a_chunk_number_with_leading_zeros_is_another_name() {
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
// visible one is not in the way of writing `_gc_watch_0`.
let mut wb = Workbook::new();
wb.names.push(name("_gc_watch_00", "\"theirs\""));
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
wb.names.push(own.clone());
set(&mut wb, "watch", "ours").unwrap();
assert_eq!(get(&wb, "watch").as_deref(), Some("ours"));
assert_eq!(wb.names.len(), 3, "both other names stay");
remove(&mut wb, "watch");
assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]);
}
#[test]
fn keys_list_items_by_prefix() {
let mut wb = Workbook::new();
set(&mut wb, "macro_2", "b").unwrap();
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
set(&mut wb, "scripts", "{}").unwrap();
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
assert_eq!(item_key("Total"), None);
}
#[test]
fn listing_many_items_takes_one_pass_and_is_capped() {
// 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each
// key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS.
let mut wb = Workbook::new();
let total = 20_000;
for i in 0..total {
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
}
set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap();
wb.names.push(name("_sc_macro_legacy", "\"old\""));
wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\""));
wb.names.push(name("_gc_other_0", "\"not a macro\""));
NAME_VISITS.with(|v| v.set(0));
let all = items(&wb, "MACRO_");
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
assert_eq!(all.len(), MAX_ITEMS);
let first: Vec<&String> = all.keys().take(2).collect();
assert_eq!(first, ["macro_00000", "macro_00001"]);
assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999"));
assert!(!all.contains_key("macro_10000"), "past the cap");
NAME_VISITS.with(|v| v.set(0));
let listed = keys(&wb, "macro_");
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len());
assert_eq!(listed, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
// Under the cap every item is there, read as `get` reads it.
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
let few = items(&wb, "macro_");
let want: BTreeMap<String, String> =
[("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect();
assert_eq!(few, want);
for (k, text) in &few {
assert_eq!(get(&wb, k).as_ref(), Some(text));
}
assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]);
assert_eq!(items(&wb, "").len(), 3, "the other item too");
}
#[test]
fn items_round_trip_through_xlsx() {
let mut wb = Workbook::new();
let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect();
set(&mut wb, "blob", &text).unwrap();
wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\""));
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str()));
assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"])));
// Characters XML can't carry, and the escape marker itself, come back too, in text and
// in JSON (which leaves U+FFFE and U+FFFF unescaped).
let mut wb = Workbook::new();
let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect();
let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect();
set(&mut wb, "odd", &text).unwrap();
excel_limits_hold(&wb);
let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"});
set_json(&mut wb, "json", &json).unwrap();
// All escapes: each literal holds whole escapes, within the limits.
let controls = "\u{1}".repeat(9_000);
set(&mut wb, "controls", &controls).unwrap();
excel_limits_hold(&wb);
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str()));
assert_eq!(get_json(&back, "json"), Some(json));
assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str()));
}
#[test]
fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() {
// Excel's limits hold however many characters are escaped, and the longest item of
// characters that all need escaping still reads.
for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] {
let text = "\u{2}".repeat(len);
let fs = chunk_formulas(&text);
assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::<String>()), text, "{len}");
assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}");
}
let mut wb = Workbook::new();
let longest = "\u{0}".repeat(MAX_ITEM_CHARS);
set(&mut wb, "longest", &longest).unwrap();
excel_limits_hold(&wb);
assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS));
// A marker in a hand-made name that is not an escape stays as it is.
let mut wb = Workbook::new();
wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\""));
assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}"));
}
}
+62 -1
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
@@ -43,6 +44,15 @@ pub fn specs() -> Vec<CommandSpec> {
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
Ok(json!({"on": s.prefs.formula_bar}))
}),
cmd!(
query "view.statusBar",
"Customize Status Bar",
[],
None,
"{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}",
always,
status_bar
),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!(
@@ -114,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Header & Footer",
["Insert", "Text"],
None,
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}",
has_doc,
header_footer
),
@@ -166,6 +176,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
"gridlines" => &mut sh.show_gridlines,
"headings" => &mut sh.show_headings,
"zeros" => &mut sh.show_zeros,
"outlineSymbols" => &mut sh.show_outline_symbols,
"rtl" => &mut sh.right_to_left,
"printGridlines" => &mut sh.print.gridlines,
_ => &mut sh.print.headings,
@@ -185,6 +196,30 @@ fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
})
}
fn status_bar(s: &mut Session, p: &Json) -> Result<Json> {
let st = &mut s.prefs.status_bar;
for (key, flag) in [
("average", &mut st.average),
("count", &mut st.count),
("numericalCount", &mut st.numerical_count),
("minimum", &mut st.minimum),
("maximum", &mut st.maximum),
("sum", &mut st.sum),
] {
if let Some(on) = bool_param(p, key) {
*flag = on;
}
}
Ok(json!({
"average": st.average,
"count": st.count,
"numericalCount": st.numerical_count,
"minimum": st.minimum,
"maximum": st.maximum,
"sum": st.sum,
}))
}
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
let d = s.doc_mut()?;
@@ -390,6 +425,12 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(on) = bool_param(p, "differentOddEven") {
pr.different_odd_even = on;
}
if let Some(on) = bool_param(p, "scaleWithDoc") {
pr.hf_scale_with_doc = on;
}
if let Some(on) = bool_param(p, "alignWithMargins") {
pr.hf_align_with_margins = on;
}
if let Some((page, footer, idx, text)) = &section {
let spec = pr.hf_mut(*page, *footer);
let mut parts = super::print::split_header_footer(spec);
@@ -403,6 +444,8 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
"footer": pr.footer,
"differentFirst": pr.different_first,
"differentOddEven": pr.different_odd_even,
"scaleWithDoc": pr.hf_scale_with_doc,
"alignWithMargins": pr.hf_align_with_margins,
"firstHeader": pr.first_header,
"firstFooter": pr.first_footer,
"evenHeader": pr.even_header,
@@ -476,4 +519,22 @@ mod tests {
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert!(old.formula_bar);
}
#[test]
fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() {
let mut s = Session::new();
let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true});
assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all);
let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap();
assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false}));
assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum);
// A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest.
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert_eq!(old.status_bar, crate::StatusBarStats::default());
let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap();
assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum);
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["statusBar"]["minimum"], true);
assert_eq!(saved["statusBar"]["numericalCount"], false);
}
}
+145 -6
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula};
use gridcraft_model::{Cell, Formula, Workbook};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
@@ -23,15 +23,43 @@ pub enum FillMode {
ValuesOnly,
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
Extend,
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
/// A lone date steps by one unit; two or more step by the difference between the first two,
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
/// extends like a plain drag.
Dates(DateUnit),
}
/// The step of a [`FillMode::Dates`] fill.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateUnit {
Day,
/// Monday to Friday: Saturday and Sunday are skipped.
Weekday,
Month,
Year,
}
impl FillMode {
/// The modes that continue the source's series rather than copy it.
fn extends_series(self) -> bool {
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
}
}
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
/// and `h:mm` don't).
fn is_date_format(fmt: &str) -> bool {
NumberFormat::parse(fmt).kind() == FormatKind::Date
}
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
/// months or years: a number (not a formula) shown in a date format.
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
}
/// Built-in custom lists.
fn lists() -> Vec<Vec<String>> {
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
@@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
let dates = match mode {
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
_ => None,
};
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
match (mode, detected) {
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
(_, p) => p,
@@ -130,7 +163,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
@@ -204,9 +237,63 @@ enum Pattern {
months: i64,
sys: gridcraft_core::DateSystem,
},
/// Dates stepping by weekdays, skipping Saturday and Sunday.
Weekdays {
last: f64,
first: f64,
step: i64,
sys: gridcraft_core::DateSystem,
},
}
impl Pattern {
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
if !is_date_format(fmt) {
return None;
}
let mut nums = Vec::with_capacity(cells.len());
for (_, cell) in cells {
let cell = cell.as_ref()?;
if cell.formula.is_some() || !cell.value.is_number() {
return None;
}
nums.push(cell.value.as_f64()?);
}
let first = *nums.first()?;
let last = *nums.last()?;
let second = nums.get(1).copied();
// The difference between the first two dates in the unit, and one unit when they fall in
// the same one.
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
Some(match unit {
DateUnit::Day => {
let days = second.map_or(1.0, |b| b.floor() - first.floor());
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
}
DateUnit::Weekday => {
let step = match second {
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
None => 1,
};
Pattern::Weekdays { last, first, step, sys }
}
DateUnit::Month | DateUnit::Year => {
let months = match second {
Some(b) => {
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
let years = b.year as i64 - a.year as i64;
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
}
None if unit == DateUnit::Year => 12,
None => 1,
};
Pattern::Months { last, first, months, sys }
}
})
}
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
@@ -343,20 +430,72 @@ impl Pattern {
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
Pattern::Weekdays { last, first, step, sys } => {
let base = if forward { *last } else { *first };
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
}
}
}
}
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
}
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
if n == 0 {
return Some(serial);
}
let dir = n.signum() as f64;
let mut d = serial;
let next_weekday = |d: &mut f64| -> Option<()> {
loop {
*d += dir;
if is_weekday(sys, *d)? {
return Some(());
}
}
};
next_weekday(&mut d)?;
// From a weekday, every seven days hold five weekdays.
let rest = n.unsigned_abs() - 1;
d += dir * (rest / 5 * 7) as f64;
for _ in 0..rest % 5 {
next_weekday(&mut d)?;
}
datetime_from_serial(sys, d)?;
Some(d)
}
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
/// counting whole days; `None` outside the supported date range.
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
let days = (hi - lo) as i64;
let mut count = days / 7 * 5;
let mut d = lo + (days / 7 * 7) as f64;
for _ in 0..days % 7 {
d += 1.0;
if is_weekday(sys, d)? {
count += 1;
}
}
Some(count * sign)
}
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
let d = datetime_from_serial(sys, serial)?;
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
let year = total.div_euclid(12);
let month = total.rem_euclid(12) + 1;
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
}
fn round15(x: f64) -> f64 {
+126 -4
View File
@@ -448,6 +448,11 @@ impl std::fmt::Write for ClipboardHtml {
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used
/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
@@ -479,6 +484,17 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
r.height()
)));
}
// The table is one growing String: every kept row writes a `<td>` for the whole used
// column span (unless the sheet has no row that is that wide). A tall, wide sheet
// (or a sparse one with a lone far-right cell) would be gigabytes before finishing,
// so the rectangle itself is bounded (#2387).
if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS {
return Err(EngineError::Other(format!(
"the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.",
r.height(),
r.width()
)));
}
if fixed {
out.push_str("<colgroup>");
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
@@ -588,14 +604,27 @@ fn script_sandbox_check(path: &str) -> Result<()> {
Ok(())
}
/// The largest native file the reader still touches: its own cap plus one, used to stop
/// reads whose bytes arrive faster than metadata can claim (device files, growing files).
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
use std::io::Read;
script_sandbox_check(path)?;
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 2 * 1024 * 1024 * 1024 {
let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
// A symlink to an endless device reports length 0 but streams forever, so metadata
// alone never bounds the read: require a real file and cap the bytes actually taken.
if !f.metadata().map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
}
let mut buf = Vec::new();
let mut limited = (&f).take(MAX_READ_BYTES + 1);
limited.read_to_end(&mut buf).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if buf.len() as u64 > MAX_READ_BYTES {
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
Ok(buf)
}
#[cfg(target_arch = "wasm32")]
@@ -606,10 +635,35 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
use std::io::Write;
script_sandbox_check(path)?;
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
// The temporary file is created exclusively, so an existing file or symlink at that
// path (planted by another local user) makes the save fail instead of truncating the
// link's target; and it inherits the destination's access restrictions, so saving a
// private workbook doesn't widen who can read it.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
if let Some(p) = &prior {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
opts.mode(p.mode());
}
let mut f = opts.open(&tmp).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})?;
#[cfg(not(unix))]
if let Some(p) = &prior {
let _ = f.set_permissions(p.clone());
}
f.write_all(bytes).and_then(|()| f.flush()).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})?;
std::fs::rename(&tmp, path).map_err(|e| {
// A write or rename that fails (full disk, the workbook locked by another app) leaves
// no temporary file beside the user's workbook.
let _ = std::fs::remove_file(&tmp);
@@ -711,6 +765,74 @@ mod tests {
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn save_fails_upfront_when_the_temp_name_is_taken() {
// Another local user who can write the directory must not be able to point the
// save's predictable temporary name at their own file: creation is exclusive.
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"original").unwrap();
let dest = dir.join("book.xlsx");
std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap();
let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err();
assert_eq!(std::fs::read(&victim).unwrap(), b"original");
assert!(!dir.join("book.xlsx").exists(), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
#[test]
fn save_keeps_the_destination_access_restrictions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("private.xlsx");
std::fs::write(&dest, b"v1").unwrap();
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
write_file(dest.to_str().unwrap(), b"v2").unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
// And a brand-new file is created with the restrictive, not permissive, default.
let fresh = dir.join("new.xlsx");
write_file(fresh.to_str().unwrap(), b"v").unwrap();
let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777;
assert!(mode & 0o022 == 0, "mode {mode:o}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn open_refuses_directories_and_unknowable_lengths() {
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file");
assert!(read_file(dir.join("missing").to_str().unwrap()).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
// #2387: the used rectangle alone bounds the output (every row writes the whole
// column span), so a sparse sheet with one cell at A1 and another far right
// 300 rows down is 4.9M cells → refused before a byte of `<td>` is written.
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into()));
sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into()));
let err = to_html(&wb, 0).unwrap_err();
assert!(err.to_string().contains("too tall × wide"), "{err}");
// A normal sheet is fine.
let mut ok = Workbook::new();
ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into()));
assert!(to_html(&ok, 0).is_ok());
}
#[test]
fn html_export_embeds_images_as_data_uris() {
let mut wb = Workbook::new();
+468 -9
View File
@@ -19,6 +19,7 @@ pub mod outline;
pub mod paper;
pub mod pivot;
pub mod pivot_calc;
pub mod record;
pub mod sample;
pub mod selection;
pub mod tables;
@@ -66,6 +67,51 @@ pub struct HistoryEntry {
pub retained_bytes: usize,
}
/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all).
#[derive(Debug, PartialEq, Eq)]
#[must_use = "an undo group stays open until it is ended or rolled back"]
pub struct GroupToken {
id: u64,
doc: u64,
}
impl GroupToken {
/// The uid of the document the group belongs to.
pub fn doc(&self) -> u64 {
self.doc
}
}
/// An open undo group: what runs on `doc` until it ends becomes one undo step.
#[derive(Clone, Debug)]
struct OpenGroup {
id: u64,
doc: u64,
label: String,
/// Undo depth when the group began; the group's own steps are cut back to it.
start: usize,
wb: Arc<Workbook>,
selection: Selection,
/// The redo stack when the group began (a rollback puts it back).
redo: Vec<HistoryEntry>,
}
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
/// command that ended the group tells its own later changes from the group's step by it, and
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
/// workbook alive between commands.
#[derive(Debug)]
struct GroupStep {
doc: u64,
after: Arc<Workbook>,
selection: Selection,
len: usize,
stepped: bool,
}
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
#[derive(Clone, Debug)]
pub struct Clipboard {
@@ -84,6 +130,34 @@ pub struct Clipboard {
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
/// together into one block at `range`. Empty for a single-range copy.
pub areas: Vec<RangeRef>,
/// Trust of the source document: pasting content from a file someone else made carries it.
pub trust: Trust,
}
/// Whether a document's active content (macros) may run without asking the person first. There
/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new
/// way of building one from bytes can't come out trusted by omission.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Trust {
/// Made in this session: a new workbook or a sample.
Local,
/// Opened from a file, or holding content pasted from one: active content asks before it
/// runs.
FromFile,
}
impl Trust {
/// Whether active content may run without asking.
pub fn allows_code(self) -> bool {
self == Trust::Local
}
/// The trust of a document after it receives content from a document with trust `source`:
/// content from an untrusted file leaves the receiver untrusted.
pub fn receive(self, source: Trust) -> Trust {
if source == Trust::FromFile { Trust::FromFile } else { self }
}
}
/// In-cell edit mode as shown in the status bar.
@@ -120,6 +194,14 @@ pub struct DocState {
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
/// trusted, since any script it gains later was written here.
pub scripts_trusted: bool,
/// Where the workbook's content came from: whether its active content may run without
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -160,17 +242,31 @@ fn push_history_with_budget(
selection: Selection,
byte_budget: usize,
) {
push_entry(history, label, wb, next, selection);
trim_to_budget(history, byte_budget);
}
/// Adds a step without applying the limits: inside a command they wait for the outermost
/// command to collapse its steps (see [`Session::trim_history`]).
fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
let retained_bytes = history_retained_bytes(&wb, next);
history.push(HistoryEntry { label, wb, selection, retained_bytes });
}
/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
/// (even if that one edit exceeds the budget).
fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
// Always preserve the latest action, even if that one edit exceeds the budget.
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
total = total.saturating_sub(history.remove(0).retained_bytes);
}
}
impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
/// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything
/// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook
/// made in this session.
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
let scripts_trusted = !cmd::extra::has_scripts(&wb);
@@ -191,6 +287,8 @@ impl DocState {
unlocked_ranges: vec![],
content_kind: None,
scripts_trusted,
trust,
go_to_recent: vec![],
}
}
pub fn is_dirty(&self) -> bool {
@@ -253,6 +351,27 @@ pub struct Prefs {
pub user_dictionary: Vec<String>,
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
pub formula_bar: bool,
/// The statistics the status bar shows for a selection (right-click the status bar).
pub status_bar: StatusBarStats,
}
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
/// Average, Count and Sum by default.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct StatusBarStats {
pub average: bool,
pub count: bool,
pub numerical_count: bool,
pub minimum: bool,
pub maximum: bool,
pub sum: bool,
}
impl Default for StatusBarStats {
fn default() -> Self {
StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
}
}
impl Default for Prefs {
@@ -267,6 +386,7 @@ impl Default for Prefs {
r1c1: false,
user_dictionary: vec![],
formula_bar: true,
status_bar: StatusBarStats::default(),
}
}
}
@@ -278,6 +398,9 @@ pub enum UiRequest {
Message(String),
/// Start editing the active cell (F2) with optional initial text.
EditCell(Option<String>),
/// Put a defined name at the caret of the cell being edited, in place of selected text; with
/// no edit under way, start editing the active cell with `=` and the name.
InsertName(String),
OpenUrl(String),
}
@@ -307,8 +430,13 @@ pub struct Session {
pub last_find: Option<Json>,
/// View › Show › Ruler is off (rulers in Page Layout view; an application option).
pub hide_ruler: bool,
/// Journal position where action recording started.
/// Journal position where Automate › Record Actions started.
pub recording: Option<usize>,
/// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level
/// commands with the place they ran in, kept apart from the journal.
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// Windows hidden with View › Hide.
pub hidden_windows: Vec<usize>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
@@ -320,6 +448,17 @@ pub struct Session {
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
/// (like Excel's comment indicator option), so it's not saved in the file.
pub show_all_notes: bool,
/// Open undo groups, innermost last.
groups: Vec<OpenGroup>,
next_group: u64,
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
group_epoch: u64,
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
group_step: Option<GroupStep>,
/// For each running command, innermost last: its document and that document's undo length
/// when it began. Steps before that length are not touched while the command runs (see
/// [`Session::bound_tail`]).
marks: Vec<(u64, usize)>,
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
/// [`cmd::format::approx_text_width`].
pub text_width: Option<TextWidth>,
@@ -359,7 +498,8 @@ impl Session {
}
pub fn close_document(&mut self, i: usize) {
if i < self.docs.len() {
self.docs.remove(i);
let d = self.docs.remove(i);
self.groups.retain(|g| g.doc != d.uid);
if self.active >= self.docs.len() {
self.active = self.docs.len().saturating_sub(1);
}
@@ -373,7 +513,7 @@ impl Session {
let name = wb.next_sheet_name();
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
}
self.add_document(DocState::new(wb, None, format!("Book{n}")))
self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local))
}
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
@@ -412,7 +552,8 @@ impl Session {
command_specs().iter().map(|c| c.info(self)).collect()
}
/// Runs a command by id. Undoable commands record a history step when the workbook changed.
/// Runs a command by id. Undoable commands record a history step when the workbook changed,
/// unless an undo group is open on the document (the group then makes the step).
/// Panics inside commands are caught and reported; the workbook is restored.
pub fn execute(&mut self, id: &str, params: Json) -> Result<Json> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
@@ -421,36 +562,352 @@ impl Session {
}
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
let params = if params.is_null() { Json::Object(Default::default()) } else { params };
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
// Whether a group was open on the document before the command, and how long its history was.
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
// A command that panics leaves the history as it found it: the steps commands nested in it
// made or undid, and the redo history they cleared, come back. A length is not enough,
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
// copy; front ends run queries all the time.
let query = !spec.journal && !spec.undoable;
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
#[cfg(test)]
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))
});
// The groups open on the document before the command (usually none), for the same reason.
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
Some((uid, ..)) => {
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
}
None => Vec::new(),
};
// The recorder keeps top-level calls with the place they ran in.
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
None => (0, 0, String::new(), Selection::default()),
});
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
let requests_before = self.ui_requests.len();
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
self.marks.extend(marked);
self.depth = self.depth.saturating_add(1);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &params)));
self.depth = self.depth.saturating_sub(1);
if marked.is_some() {
self.marks.pop();
}
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
// Restore the workbook as it was.
// Restore the workbook and its history as they were, and drop the undo groups the
// command opened.
self.groups.retain(|g| g.id < first_group);
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
if let Some((uid, wb, sel)) = &before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
{
d.wb = wb.clone();
d.selection = sel.clone();
if let Some((undo, redo)) = history_before {
d.undo = undo;
d.redo = redo;
}
// A group open before the command and ended or rolled back inside it stays
// ended (its token went with the command), and keeps a step from its start to
// the restored workbook, so Undo still returns to the state before the group.
if !group_open
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
&& !Arc::ptr_eq(&wb, &d.wb)
{
push_entry(&mut d.undo, label, wb, &d.wb, selection);
d.redo.clear();
}
d.calc.rebuild(&d.wb);
}
if self.depth == 0 {
self.trim_history();
self.group_step = None;
}
return Err(EngineError::Internal(id.to_string(), msg));
}
};
// A command that fails leaves none of its own undo groups open (a `?` between
// `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a
// command that succeeds keeps a group it opened (a debugger's start).
if result.is_err() {
self.rollback_groups_from(first_group);
}
if spec.journal && result.is_ok() {
// A command that started recording is not part of the recording; one that stopped it
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
// call is, so playing the recording back runs the command once, with its parameters.
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
&& let Some(rec) = self.recorder.as_mut()
{
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
}
self.push_journal(id, cmd::journal_params(&params));
}
// Inside an undo group the group owns the step. A command that ended a group open before
// it leaves the group its step and makes one of its own for what it changed after the end.
// A command that opened and closed its own groups makes one step of its own, which
// replaces the steps those groups made.
let epoch_changed = self.group_epoch != group_epoch;
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
let was_open = start.is_some_and(|(was_open, _)| was_open);
if spec.undoable
&& let Some((uid, wb, sel)) = before
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&d.wb, &wb)
{
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
let label = spec.label.trim_end_matches('…').to_string();
if in_group_now {
// A group this command opened and left open (a debugger's start) takes the
// command's start as its own, so what the command changed before opening it is
// part of the group's step and not a step of its own.
if !was_open
&& let Some((_, len)) = start
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
{
g.start = len;
g.wb = wb;
g.selection = sel;
}
} else if epoch_changed && was_open {
// The group it ended made its step; changes after the end, whether direct or by
// nested commands, are the command's own step from where the group ended.
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
{
d.undo.truncate(step.len);
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
d.redo.clear();
}
} else {
let mut label = label;
if epoch_changed && let Some((_, len)) = start {
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
// That is so only when the one entry the command added is a group's step from
// the command's own start to its own result: an entry a nested command made,
// a group step of an earlier command (undone since), or a group's step that
// the command edited past all name the step after the command instead.
if d.undo.len() == len + 1
&& let Some(last) = d.undo.last()
&& Arc::ptr_eq(&last.wb, &wb)
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
{
label = last.label.clone();
}
d.undo.truncate(len);
}
push_entry(&mut d.undo, label, wb, &d.wb, sel);
d.redo.clear();
}
// Inside a group the redo history waits for the group: `group_end` clears it when the
// group becomes a step, and `group_rollback` puts it back as it was.
d.revision += 1;
}
// The history limit applies once the outermost command has collapsed its steps, so the
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
if self.depth == 0 {
self.trim_history();
self.group_step = None;
} else if let Some(uid) = doc_uid {
self.bound_tail(uid);
}
result
}
/// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f`
/// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
let token = self.group_begin(label)?;
let doc = token.doc();
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
// (outside `execute`, which drops a command's groups itself) finds no group left open.
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
Ok(Ok(r)) => match self.group_end(token) {
Ok(_) => Ok(r),
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
// an untouched new workbook), and the group went with it: nothing is left to undo.
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
Err(e) => Err(e),
},
Ok(Err(e)) => {
// The error from `f` is the one to report. A rollback fails only when the group
// has already ended, and then there is nothing left to undo.
let _ = self.group_rollback(token);
Err(e)
}
Err(panic) => {
let _ = self.group_rollback(token);
std::panic::resume_unwind(panic)
}
}
}
/// Opens an undo group on the active document that can span several calls (for example a
/// debugger that runs one step per call). Until [`Session::group_end`] or
/// [`Session::group_rollback`], undoable commands on that document add no undo steps of their
/// own, and Undo and Redo are unavailable there.
pub fn group_begin(&mut self, label: &str) -> Result<GroupToken> {
let d = self.doc()?;
let g = OpenGroup {
id: self.next_group,
doc: d.uid,
label: label.trim_end_matches('…').to_string(),
start: d.undo.len(),
wb: d.wb.clone(),
selection: d.selection.clone(),
redo: d.redo.clone(),
};
let token = GroupToken { id: g.id, doc: g.doc };
self.next_group = self.next_group.saturating_add(1);
self.group_epoch = self.group_epoch.wrapping_add(1);
self.groups.push(g);
Ok(token)
}
/// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is
/// nested in another open group on the same document, which then owns the step. Groups opened
/// inside it and still open end with it. Returns whether the workbook changed.
pub fn group_end(&mut self, token: GroupToken) -> Result<bool> {
let g = self.take_group(&token)?;
let outer = self.in_group(g.doc);
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) };
// Steps pushed directly while the group was open fold into the group's step.
d.undo.truncate(g.start);
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
if changed && !outer {
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
d.redo.clear();
d.revision += 1;
}
result
if !outer {
self.group_step =
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
}
// Inside a command, the outermost command applies the history limit when it finishes.
if self.depth == 0 {
self.trim_history();
}
Ok(changed)
}
/// Stops the running action on the document with uid `doc`: every undo group open on it is
/// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo
/// and redo history as they were when the outermost one began. Their tokens are then spent
/// (ending or rolling one back reports that it has already ended). Returns whether a group
/// was open. `automate.stopAction` runs it on the active document.
pub fn abort_groups(&mut self, doc: u64) -> bool {
let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false };
// Rolling back the outermost group ends the ones opened inside it.
self.group_rollback(GroupToken { id, doc }).is_ok()
}
/// Rolls back every open undo group numbered `first` or later (the groups a failed command
/// opened), outermost first on each document.
fn rollback_groups_from(&mut self, first: u64) {
let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect();
for (id, doc) in opened {
// A group inside one rolled back before it is already gone.
if self.groups.iter().any(|g| g.id == id) {
let _ = self.group_rollback(GroupToken { id, doc });
}
}
}
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
/// Only steps after the latest mark go: a running command's start, an open group's start and
/// the end of the last group are positions the command or group collapses its steps by when
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
fn bound_tail(&mut self, uid: u64) {
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
}
}
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
/// an open group waits for the group to end, because the group remembers where its steps start.
fn trim_history(&mut self) {
let groups = &self.groups;
for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
}
}
/// Ends an undo group by putting back the workbook, selection, undo and redo history as they
/// were when it began.
pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> {
let g = self.take_group(&token)?;
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) };
if !Arc::ptr_eq(&d.wb, &g.wb) {
d.wb = g.wb;
d.calc.rebuild(&d.wb);
}
d.selection = g.selection;
d.undo.truncate(g.start);
d.redo = g.redo;
d.revision += 1;
// The record of the last group step stays when the document is back exactly where that
// step left it (this group began there); otherwise the rollback is where the group ended.
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
}
Ok(())
}
/// Starts recording top-level commands into an empty buffer, replacing any recording in
/// progress. Queries, failed commands and commands run by other commands are not recorded.
pub fn start_recording(&mut self) {
self.recorder = Some(record::Recorder::default());
}
/// Stops recording and returns what was recorded (`None` when nothing was recording).
pub fn stop_recording(&mut self) -> Option<record::Recorder> {
self.recorder.take()
}
/// Whether the recording buffer is collecting commands.
pub fn is_recording(&self) -> bool {
self.recorder.is_some()
}
/// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a
/// command run by a command, and so on.
pub fn depth(&self) -> u32 {
self.depth
}
/// Whether an undo group is open on the document with this uid.
pub fn in_group(&self, doc: u64) -> bool {
self.groups.iter().any(|g| g.doc == doc)
}
/// Removes the token's group and any group on the same document opened after it.
fn take_group(&mut self, token: &GroupToken) -> Result<OpenGroup> {
let ended = || EngineError::Other("That undo group has already ended.".into());
let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?;
let mut later = self.groups.split_off(at).into_iter();
let g = later.next().ok_or_else(ended)?;
self.groups.extend(later.filter(|o| o.doc != g.doc));
self.group_epoch = self.group_epoch.wrapping_add(1);
Ok(g)
}
pub fn run(&mut self, id: &str, params: Json) -> std::result::Result<Json, String> {
@@ -468,6 +925,8 @@ impl Session {
let r = self.execute(id, params);
let requests = self.take_ui_requests();
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
// A call that only opened a dialog is not recorded (see `execute`), so the recording
// has no step to take back here.
if r.is_ok() && asked_for_file {
let what = match id {
"file.open" => "`path` or `base64`",
+45 -9
View File
@@ -1,17 +1,18 @@
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
//! columns right of it, or above / left of it when the sheet's outline settings say so
//! ([`Sheet::summary_below`], [`Sheet::summary_right`]).
use std::collections::BTreeMap;
use gridcraft_model::LineInfo;
use gridcraft_model::{LineInfo, Sheet};
/// The deepest outline level XLSX allows.
pub const MAX_LEVEL: u8 = 7;
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
/// just after `end`.
/// just after `end`, or just before `start` when `summary_before` is set.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutlineGroup {
pub level: u8,
@@ -19,25 +20,43 @@ pub struct OutlineGroup {
pub end: u32,
/// Every detail line is hidden: the button shows +.
pub collapsed: bool,
/// The summary line is above (rows) or left of (columns) the detail.
pub summary_before: bool,
}
impl OutlineGroup {
/// The summary line, where the − / + button sits.
pub fn summary(&self) -> u32 {
self.end.saturating_add(1)
if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) }
}
/// Whether the summary line exists: a group that starts at the first line (summary before)
/// or ends at the last one (summary after) has none.
pub fn has_summary(&self) -> bool {
!self.contains(self.summary())
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail.
pub fn summary_before(sh: &Sheet, rows: bool) -> bool {
if rows { !sh.summary_below } else { !sh.summary_right }
}
/// The deepest outline level used by these lines (0 = no outline).
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
}
/// Every group, ordered by where it ends (inner groups before the group around them).
/// Every group, with its summary line after the detail; see [`groups_with`].
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
groups_with(lines, false)
}
/// Every group, ordered by where it ends (inner groups before the group around them), with its
/// summary line before the detail when `summary_before` is set.
pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
let mut out = Vec::new();
// Open group per level (index level - 1): start line and whether all its lines are hidden.
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
@@ -47,7 +66,7 @@ pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
if let Some(slot) = open.get_mut(level as usize - 1)
&& let Some((start, hidden)) = slot.take()
{
out.push(OutlineGroup { level, start, end, collapsed: hidden });
out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before });
}
}
};
@@ -102,9 +121,9 @@ mod tests {
assert_eq!(
g,
vec![
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false },
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false },
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false },
]
);
assert_eq!(g[1].summary(), 8);
@@ -129,5 +148,22 @@ mod tests {
assert_eq!(g.len(), 7);
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
assert_eq!(g[0].summary(), u32::MAX);
assert!(!g[0].has_summary());
}
#[test]
fn summary_before_the_detail() {
// Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5.
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]);
let g = groups_with(&l, true);
let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied();
assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true)));
assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true)));
// A group on the first line has no line before it.
assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false)));
let mut sh = Sheet::new("S");
assert!(!summary_before(&sh, true) && !summary_before(&sh, false));
sh.summary_below = false;
assert!(summary_before(&sh, true) && !summary_before(&sh, false));
}
}
+38 -2
View File
@@ -29,6 +29,12 @@ pub struct PageGrid {
pub gap: f64,
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
pub scale: f32,
/// Left and right margins of headers and footers in layout units (the side margins, or a
/// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off).
pub hf_sides: [f64; 2],
/// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on,
/// otherwise `1 / scale`, so the text keeps its point size on paper.
pub hf_text_k: f32,
/// Paper size in points (after orientation), for the rulers.
pub paper_pt: (f32, f32),
pub row_bands: Vec<(u32, u32)>,
@@ -65,6 +71,8 @@ impl PageGrid {
let s = f64::from(lay.scale.clamp(0.1, 4.0));
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
let margins = lay.margins.map(to_layout);
let (hf_left, hf_right) = lay.hf_sides();
let hf_text_k = lay.hf_text_scale() / s as f32;
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
@@ -79,6 +87,8 @@ impl PageGrid {
margins,
gap: f64::from(PAGE_GAP) / s,
scale: lay.scale,
hf_sides: [to_layout(hf_left), to_layout(hf_right)],
hf_text_k,
paper_pt: (lay.page_w, lay.page_h),
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
@@ -174,11 +184,12 @@ impl PageGrid {
}
/// The header or footer section under the point: the top or bottom margin of a page, split
/// into thirds between the side margins.
/// into thirds between the header and footer side margins.
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
let (i, j) = self.page_at(x, y)?;
let (px, py, _, _) = self.page_rect(i, j);
let [l, r, t, b, ..] = self.margins;
let [_, _, t, b, ..] = self.margins;
let [l, r] = self.hf_sides;
let footer = if y < py + t {
false
} else if y >= py + self.page_h - b {
@@ -301,6 +312,31 @@ mod tests {
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
}
#[test]
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
let mut wb = wb_with(10, 3);
if let Some(sh) = wb.sheet_mut(0) {
sh.print.scale = 50;
sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3];
}
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
let (px, py, _, _) = g.page_rect(0, 0);
// Both on: the sections sit between the 2-inch side margins and the text scales with the cells.
assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]);
assert!((g.hf_text_k - 1.0).abs() < 1e-6);
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2));
if let Some(sh) = wb.sheet_mut(0) {
sh.print.hf_scale_with_doc = false;
sh.print.hf_align_with_margins = false;
}
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
// Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the
// text keeps its point size on paper.
assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides);
assert!((g.hf_text_k - 2.0).abs() < 1e-6);
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1));
}
#[test]
fn print_scale_and_hostile_settings_stay_bounded() {
let mut wb = wb_with(3, 3);
+75
View File
@@ -803,6 +803,70 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
compute_from(wb, pt, &src, None)
}
/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end
/// edits that cell instead).
pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
/// Show Details: the source list and the indices of its records behind the value cell at
/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when
/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None };
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
// Checked first: in a stale report even a cell that computes as a label may show a value.
check_current(wb, pt, pivot_sheet, &out)?;
let Some((rp, cp)) = probe.hit else { return Ok(None) };
let records =
probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
Ok(Some((src, records)))
}
/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds
/// records: its value, subtotal and grand total cells, from one computation of the report. The
/// same errors as [`detail_records`].
pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<HashSet<(u32, u32)>, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) };
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
check_current(wb, pt, pivot_sheet, &out)?;
Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect())
}
/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable
/// cache; without one, the report written by the last refresh must still be what the source gives.
fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> {
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
let current = pt.last_range.is_some_and(|r| {
r.height() == out.height().max(1)
&& r.width() == out.width.max(1)
&& out.grid.iter().enumerate().all(|(ri, line)| {
line.iter().enumerate().all(|(ci, oc)| {
sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value
})
})
});
if !current {
return Err("The PivotTable report is out of date. Refresh it first.".into());
}
Ok(())
}
/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column
/// item paths of the value cell at `at`, the paths of every record the filters let through (when
/// `paths` is set), and every value cell (when `cells` is set).
struct Probe {
at: Option<(usize, usize)>,
hit: Option<(Vec<u32>, Vec<u32>)>,
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
cells: Option<HashSet<(usize, usize)>>,
}
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
@@ -914,6 +978,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec());
}
if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) {
paths.push((ri, rp.clone(), cp.clone()));
}
row_set.insert(rp);
col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
@@ -1278,6 +1345,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
_ => raw,
},
};
if let Some(pr) = probe.as_deref_mut() {
if pr.at == Some((r, l + j)) {
pr.hit = Some((rp.to_vec(), cp.to_vec()));
}
if let Some(cells) = pr.cells.as_mut() {
cells.insert((r, l + j));
}
}
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
}
}
+66
View File
@@ -0,0 +1,66 @@
//! The recording buffer: the commands a person (or an agent) ran while recording, each with the
//! document, sheet and selection it ran in.
//!
//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a
//! script) is one step, so playing the recording back does not run the inner commands twice. The
//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and
//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]).
use serde::Serialize;
use serde_json::Value as Json;
use crate::Selection;
/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`].
pub const RECORD_LIMIT: usize = 100_000;
/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full
/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
/// One recorded command and where it ran.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordedStep {
/// Uid of the document that was active when the command ran (0 when none was open).
pub doc: u64,
/// That document's active sheet (index and name) just before the command ran.
pub sheet: usize,
pub sheet_name: String,
/// That document's selection just before the command ran.
pub selection: Selection,
/// The command id and its parameters, without secrets.
pub id: String,
pub params: Json,
}
/// The steps recorded since recording started.
#[derive(Clone, Debug, Default)]
pub struct Recorder {
pub steps: Vec<RecordedStep>,
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
/// what ran, never a sequence with gaps.
pub dropped: usize,
/// The size of `steps` serialized as JSON.
bytes: usize,
}
impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full.
pub fn push(&mut self, step: RecordedStep) {
// Once a step has been dropped, or the step count is full, the rest are counted without
// being serialized (a `file.open` step holds a whole file).
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
self.dropped = self.dropped.saturating_add(1);
return;
}
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
self.bytes = self.bytes.saturating_add(size);
self.steps.push(step);
} else {
self.dropped = self.dropped.saturating_add(1);
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option<Workbook> {
_ => return None,
};
}
s.add_document(DocState::new(wb, None, title(name)));
s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local));
let ok = match name {
"budget" => budget(&mut s),
"sales" => sales(&mut s),
+193
View File
@@ -3,6 +3,11 @@ use serde_json::json;
use crate::Session;
thread_local! {
/// Copies of a document's undo and redo history `execute` has taken on this thread.
pub(crate) static HISTORY_COPIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn s() -> Session {
let mut s = Session::new();
s.new_workbook();
@@ -936,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
}
#[test]
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
let mut s = s();
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
// Friday 1/30/2026 is serial 46052.
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
// From Saturday 1/31, the first weekday is Monday 2/2.
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
// A number that isn't a date fills as a plain drag does: a lone number copies.
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
assert_eq!(v(&s, "F2"), Value::Number(7.0));
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
// a month apart, and the Series dialog by months.
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
}
#[test]
fn only_a_number_in_a_date_format_offers_the_date_fills() {
let mut s = s();
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
assert!(!dates(&s, "A4"), "a formula is not offered");
assert!(!dates(&s, "A5"), "a time is not a date");
assert!(!dates(&s, "A6"), "an empty cell is not a date");
}
#[test]
fn insert_delete_cells_adjust() {
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
@@ -2720,6 +2805,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() {
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
}
/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting.
#[test]
fn show_outline_symbols_toggles() {
let mut s = s();
let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols;
assert!(on(&s));
assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false}));
assert!(!on(&s));
s.execute("edit.undo", json!({})).unwrap();
assert!(on(&s));
s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap();
reopen(&mut s);
assert!(!on(&s));
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
@@ -2764,6 +2864,42 @@ fn calculation_options_enable_iterative_calculation() {
near(&s, "A1", 10.0);
}
#[test]
fn error_checking_quotes_sheet_names_in_circular_references() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
let r = s.execute("formulas.errorChecking", json!({})).unwrap();
assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"]));
// The reference reads back in a formula and in Go To.
let reference = r["circular"][0].as_str().unwrap().to_string();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(2.0));
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
let d = s.doc().unwrap();
assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string()));
}
/// Undo and redo put cells back without calculating them; the circular references listed are
/// those of the restored cells.
#[test]
fn undo_and_redo_list_the_circular_references_of_the_restored_cells() {
let mut s = s();
let listed = |s: &Session| s.doc().unwrap().calc.circular.iter().map(|(si, c)| (*si, c.a1())).collect::<Vec<_>>();
s.execute("cell.set", json!({"cell": "A5", "input": "=A5+1"})).unwrap();
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
// Undoing the loop itself lists none.
s.execute("edit.undo", json!({"steps": 2})).unwrap();
assert!(listed(&s).is_empty());
}
fn chart_data(s: &mut Session) {
s.execute(
"range.setValues",
@@ -4584,3 +4720,60 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
}
#[test]
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
// content edits, so the format cells may not drop a cell's `locked` flag.
let mut s = s();
s.execute("cell.set", json!({"cell": "B2", "input": "hi"})).unwrap();
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
s.execute("home.fontName", json!({"name": "Arial"})).unwrap();
let err = s.execute("home.lockCell", json!({"range": "B2", "on": false})).expect_err("unlock through formatting");
assert!(err.to_string().contains("protected"), "{err}");
assert!(s.execute("cell.set", json!({"cell": "B2", "input": "x"})).is_err(), "B2 stays locked");
// The same calls run once the sheet is unprotected.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
assert_eq!(v(&s, "B2"), Value::text("x"));
}
#[test]
fn move_columns_checks_the_protection_of_the_named_sheet() {
// The move's protection checks must authorize the sheet it modifies, not whichever sheet
// happens to be active while the command names another one.
let mut s = s();
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let err = s.execute("sheet.moveColumns", json!({"sheet": "P", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
assert!(err.to_string().contains("protected"), "{err}");
let pv: Vec<Value> = {
let d = s.doc().unwrap();
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
(0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect()
};
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
// The active, unprotected sheet moves fine.
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap();
s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap();
assert_eq!(v(&s, "A1"), Value::text("A"));
}
#[test]
fn a_script_cannot_switch_windows() {
// Stored scripts are scoped to their workbook: a step that activates another window would
// point every later step at another open document, so it is refused before any step runs.
let mut s = s();
s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap();
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
assert!(err.to_string().contains("window.activate"), "{err}");
// A benign script runs.
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
assert_eq!(v(&s, "A1"), Value::number(2.0));
}
mod seams;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype
//! `stacked100`.
use gridcraft_engine::Session;
use gridcraft_model::ChartKind;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap();
s
}
fn kinds(s: &Session) -> Vec<ChartKind> {
s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect()
}
#[test]
fn insert_chart_makes_a_hundred_percent_stacked_area() {
let mut s = session();
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
}
#[test]
fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() {
let mut s = session();
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
}
@@ -23,7 +23,7 @@
//!
//! Knobs (environment variables):
//! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`.
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`.
//! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line).
//! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that
//! still fails the same way, printed as `execute` calls ready for a regression test.
@@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure)
None => format!("end-of-run checks after {} steps", sequence.len()),
};
let mut out = format!(
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n",
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n",
f.kind, f.what
);
let end = (f.step + 1).min(sequence.len());
@@ -26,7 +26,7 @@ fn opened() -> Session {
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
let mut s = Session::new();
s.add_document(DocState::new(back, None, "dynamic".into()));
s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile));
s
}
@@ -0,0 +1,61 @@
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap();
s
}
fn styles(s: &Session) -> Vec<String> {
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect()
}
#[test]
fn format_as_table_inside_a_table_restyles_it() {
let mut s = table();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_on_a_range_inside_a_table_restyles_it() {
let mut s = table();
s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap();
assert_eq!(styles(&s), ["TableStyleDark3"]);
}
#[test]
fn format_as_table_across_a_table_edge_is_rejected() {
let mut s = table();
let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err();
assert!(e.to_string().contains("overlap"), "{e}");
assert_eq!(styles(&s), ["TableStyleMedium2"]);
}
#[test]
fn format_as_table_inside_a_table_returns_that_table() {
let mut s = table();
let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap();
assert_eq!(r, json!({"table":"T", "range":"A1:B3"}));
}
#[test]
fn format_as_table_uses_the_active_area_of_a_multi_area_selection() {
let mut s = table();
s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() {
let mut s = table();
s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
@@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() {
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
let mut session = Session::new();
session.add_document(DocState::new(reopened, None, "chains".into()));
session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile));
session.execute("formulas.calculateNow", json!({})).expect("recalc");
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
+30
View File
@@ -0,0 +1,30 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod autofit_filter_buttons;
mod cf_formula_validate;
mod chart_area_stacked100;
mod chart_workbook_names;
mod command_sequence_fuzz;
mod cross_sheet_cut;
mod csv_export_budget;
mod custom_filter_blanks;
mod custom_view_freeze;
mod dynamicmetadata;
mod extreme_selection_moves;
mod format_as_table_restyle;
mod hidden_sheet_navigation;
mod ink_roundtrip;
mod line_count_overflow;
mod long_operator_chains;
mod offset_overflow;
mod percent_entry;
mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod row_autofit;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod table_resize_overlap;
mod table_total_row_last_row;
mod wrap_row_height;
@@ -6,7 +6,8 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\
fn with_name(name: &str, refers_to: &str) -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap();
// `force`: the name commands refuse GridCraft's own data names otherwise.
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap();
s
}
+14 -12
View File
@@ -23,18 +23,20 @@ pub fn same_name(a: &str, b: &str) -> bool {
if a.is_ascii() && b.is_ascii() {
return a.eq_ignore_ascii_case(b);
}
let fold = |s: &str| -> String {
s.chars()
.flat_map(char::to_lowercase)
.flat_map(|c| match c {
'ß' => ['s', 's'].into_iter().take(2),
'ſ' => ['s', 's'].into_iter().take(1),
'ς' => ['σ', 'σ'].into_iter().take(1),
c => [c, c].into_iter().take(1),
})
.collect()
};
a == b || fold(a) == fold(b)
a == b || fold_name(a) == fold_name(b)
}
/// The case-folded form of a name: two names are [`same_name`] when their folded forms are equal.
pub fn fold_name(s: &str) -> String {
s.chars()
.flat_map(char::to_lowercase)
.flat_map(|c| match c {
'ß' => ['s', 's'].into_iter().take(2),
'ſ' => ['s', 's'].into_iter().take(1),
'ς' => ['σ', 'σ'].into_iter().take(1),
c => [c, c].into_iter().take(1),
})
.collect()
}
#[cfg(test)]
+33 -2
View File
@@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String {
write_struct_ref(s, false)
}
/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets
/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`).
fn sheet_split(n: &str) -> Option<(&str, &str)> {
let mut depth = 0usize;
let mut escaped = false;
let mut at = None;
for (i, c) in n.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\'' if depth > 0 => escaped = true,
'[' => depth = depth.saturating_add(1),
']' => depth = depth.saturating_sub(1),
'!' if depth == 0 => at = Some(i),
_ => {}
}
}
at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?)))
}
fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#");
let item = |i: &StructItem| match i {
@@ -132,7 +154,16 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
}
fn number(n: f64) -> String {
number_to_text(n)
let text = number_to_text(n);
// 15 significant digits can round past f64::MAX, which no longer parses.
if text.parse::<f64>().is_ok_and(f64::is_finite) {
return text;
}
let exact = format!("{:E}", n);
match exact.split_once('E') {
Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"),
_ => exact,
}
}
/// Prints an expression in A1 notation.
@@ -178,7 +209,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
Some(at) => s.push_str(&reference_r1c1(r, at)),
None => s.push_str(&reference_a1(r)),
},
Expr::Name(n) => match n.rsplit_once('!') {
Expr::Name(n) => match sheet_split(n) {
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
Some((sheet, name)) => {
s.push_str(&quote_sheet(sheet));
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod printer_roundtrip;
mod spanish_input;
@@ -0,0 +1,15 @@
use gridcraft_formula::{Expr, parse, print};
#[test]
fn number_near_f64_max_round_trips() {
for src in ["=1.7976931348623157E308", "=-1.7976931348623157E308"] {
let e = parse(src).unwrap();
let printed = print(&e);
let back = parse(&printed).unwrap_or_else(|err| panic!("{printed}: {err:?}"));
assert_eq!(print(&back), printed);
match (&e, &back) {
(Expr::Number(a), Expr::Number(b)) => assert_eq!(a, b),
_ => assert_eq!(e, back),
}
}
}
+44 -7
View File
@@ -264,15 +264,27 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool {
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
let p = tokenize(pattern);
let t = lower_chars(text);
match_from(&p, &t, true).is_some()
let mut budget = MAX_STEPS;
match_from(&p, &t, true, &mut budget).is_some()
}
/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character
/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead
/// of quadratic — a call that spends more gives up and reports no match, quiet degradation
/// like the other work caps on far-past-realistic input.
const MAX_STEPS: u64 = 1 << 26;
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish).
fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish,
/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that
/// would spend more than it has gives up and reports no match — quiet degradation, as with the
/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char
/// cell ceiling costs a few hundred thousand steps).
fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<usize> {
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
loop {
*budget = budget.checked_sub(1)?;
if pi == p.len() && (!whole || ti == t.len()) {
return Some(ti);
}
@@ -307,14 +319,38 @@ fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
let p = tokenize(pattern);
let t = lower_chars(text);
// A leading run of stars never matters for a prefix match (it consumes nothing), so
// dropping it lets the anchor below skip linearly through the text (#2382).
let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count();
let p = &p[skip.min(p.len())..];
if p.is_empty() {
return if start <= t.len() { Some(start) } else { None };
}
(start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some()))
// The first token that consumes a character anchors the scan: a literal skips straight
// to its occurrences, and after leading `?`s each attempt only pays for the `?` run and
// one suffix try. Every attempt shares one budget, so the total work stays flat.
let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count();
let anchor = p.get(anys);
let mut budget = MAX_STEPS;
let mut i = start;
while i < t.len() {
let hit = match (anchor, t.get(i + anys)) {
(Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(),
(Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead
_ => match_from(p, &t[i..], false, &mut budget).is_some(),
};
if hit {
return Some(i);
}
i += 1;
}
None
}
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively.
/// matches by pattern (only when `wild`), otherwise values compare case-insensitively. Numbers
/// compare exactly, not at the 15 digits of `=` and the criteria functions (Excel 365:
/// `MATCH(0.1+0.2,{0.3},0)` is #N/A while `0.1+0.2=0.3` is TRUE).
pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
match (needle, hay) {
(Value::Text(p), Value::Text(t)) => {
@@ -324,7 +360,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
compare_text(p, t) == Ordering::Equal
}
}
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
(Value::Number(a), Value::Number(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
(Value::Empty, Value::Empty) => true,
@@ -370,7 +406,8 @@ mod tests {
assert!(m("#N/A", Value::Error(CellError::NA)));
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false));
assert!(m("<>a*", Value::from("bcd")));
assert!(m(">1/1/2020", Value::Number(44000.0)));
// Text patterns never match blank cells, but an empty string is text.
+3 -2
View File
@@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
match v {
Value::Number(n) => {
let i = n.trunc();
if i < 1.0 || i > db.cols as f64 {
// NaN too: it would pass both comparisons and become index 0 (#2163).
if i.is_nan() || i < 1.0 || i > db.cols as f64 {
return Err(CellError::Value);
}
Ok(Some(i as usize - 1))
Ok(Some((i as usize).saturating_sub(1)))
}
Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value),
Value::Bool(_) => Err(CellError::Value),
+58 -2
View File
@@ -13,12 +13,32 @@ use crate::{Arg, Ctx, FnSpec, VAR};
// Time value of money
// ---------------------------------------------------------------------------------------------
/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from
/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to
/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan
/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a
/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows.
fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 {
if rate == 0.0 {
return -(pv + fv) / nper;
}
let p = (1.0 + rate).powf(nper);
-(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0))
if rate <= -1.0 {
return f64::NAN;
}
let l = nper * rate.ln_1p();
let m = l.exp_m1();
let k = 1.0 + rate * typ;
if m > 0.0 {
// (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1.
// rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow.
-(pv * rate + (pv + fv) * (rate / m)) / k
} else if m > -0.5 {
// p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop.
-(rate * (pv + fv + pv * m)) / (k * m)
} else {
// p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself.
-(rate * (fv + pv * l.exp())) / (k * m)
}
}
fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 {
@@ -1790,6 +1810,42 @@ mod tests {
is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num);
}
/// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across
/// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`.
#[test]
fn pmt_matches_excel_within_a_few_ulps() {
for (rate, nper, pv, fv, typ, excel) in [
(0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904),
(0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778),
(0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775),
(0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324),
(0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787),
(-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645),
] {
let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]);
close_tol(got, excel, 1e-15);
}
}
/// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a
/// tiny rate no longer loses its low digits in 1 + rate.
#[test]
fn pmt_edge_cases_follow_excel() {
is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num);
is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num);
is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num);
assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0));
assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0));
assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0));
close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15);
close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15);
close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15);
close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15);
close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9);
close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6);
close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15);
}
#[test]
fn cash_flows() {
close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9);
+52
View File
@@ -30,6 +30,7 @@ macro_rules! f {
category: $crate::Category::$cat,
volatile: true,
scalar_args: $scal,
value_args: &[],
signature: $sig,
description: $desc,
imp: {
@@ -48,6 +49,7 @@ macro_rules! f {
category: $crate::Category::$cat,
volatile: false,
scalar_args: $scal,
value_args: &[],
signature: $sig,
description: $desc,
imp: {
@@ -156,6 +158,11 @@ pub struct FnSpec {
/// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an
/// empty slice means no lifting.
pub scalar_args: &'static [bool],
/// Parameter positions that take a single value and reject a reference to more than one
/// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…),
/// where other scalar parameters lift the range. Arrays that aren't references still lift.
/// Same layout as `scalar_args`; filled at registration from [`lift::value_args`].
pub value_args: &'static [bool],
pub signature: &'static str,
pub description: &'static str,
pub imp: FnImpl,
@@ -176,6 +183,14 @@ impl FnSpec {
}
}
/// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`.
pub fn is_value_arg(&self, i: usize) -> bool {
match self.value_args.get(i) {
Some(b) => *b,
None => self.value_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
@@ -204,6 +219,9 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
] {
v.extend(list);
}
for s in &mut v {
s.value_args = lift::value_args(s.name);
}
let mut map = HashMap::with_capacity(v.len());
for (i, s) in v.iter().enumerate() {
map.entry(s.name).or_insert(i);
@@ -264,6 +282,37 @@ mod tests {
}
}
/// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`).
#[test]
fn nan_index_is_a_value_error() {
use crate::util::testutil::*;
use gridcraft_core::CellError;
let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]]));
let nan = || av(Value::Number(f64::NAN));
assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value));
assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value));
assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value));
assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0));
}
/// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces
/// as before (every match, `$n` groups).
#[test]
fn regexreplace_bounds_its_result() {
use crate::util::testutil::*;
use gridcraft_core::CellError;
let many = "a".repeat(32_767);
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value));
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767)));
assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c"));
assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026"));
assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-"));
assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#"));
// One match whose replacement repeats it 10,000 times: refused before it is built.
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value));
assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value));
}
#[test]
fn hostile_inputs_never_panic() {
use crate::util::testutil::*;
@@ -279,6 +328,9 @@ mod tests {
n(-1e308),
n(1e10),
n(2.5),
// Numbers no formula should make, but code can (#2163).
av(Value::Number(f64::NAN)),
av(Value::Number(f64::INFINITY)),
t(""),
t("abc"),
t("*?~"),
+87
View File
@@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value};
use crate::util::MAX_CELLS;
use crate::{Arg, Ctx, FnSpec};
/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more
/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no
/// implicit intersection), while array constants and computed arrays lift. Range parameters
/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend
/// (which lifts a range) are `false`.
pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[
// Date and time.
("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]),
("WORKDAY NETWORKDAYS", &[true, true, false]),
("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]),
// Financial.
(
"ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \
DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \
PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT",
&[true],
),
("FVSCHEDULE XNPV", &[true, false]),
("XIRR", &[false, false, true]),
// Math and information.
("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]),
("SERIESSUM", &[true, true, true, false]),
// Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here).
(
"BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \
ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \
IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \
IMTAN",
&[true],
),
];
/// The value-parameter mask of `name` (empty for functions without value parameters).
pub(crate) fn value_args(name: &str) -> &'static [bool] {
VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask)
}
/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds
/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel.
fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1);
if !args.iter().enumerate().any(|(i, a)| multi(i, a)) {
return None;
}
Some(
args.iter()
.enumerate()
.map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() })
.collect(),
)
}
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
let args = rejected.as_deref().unwrap_or(args);
let mut rows = 0usize;
let mut cols = 0usize;
let mut any = false;
@@ -94,4 +148,37 @@ mod tests {
);
is_err(huge, CellError::Num);
}
#[test]
fn value_params_reject_multi_cell_references() {
let s = FnSpec { value_args: &[true, false], ..spec() };
let mut ctx = TestCtx::default();
// A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine.
is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value);
assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0));
assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]);
// The rejected reference adds no shape: the other argument still lifts.
let v = Value::Error(CellError::Value);
assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]);
// A position that isn't a value parameter still lifts the reference.
assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]);
}
#[test]
fn value_arg_table_names_registered_scalar_positions() {
for (names, mask) in super::VALUE_ARGS {
assert!(!mask.is_empty());
for name in names.split_ascii_whitespace() {
let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") };
assert_eq!(s.value_args, *mask, "{name}");
for i in 0..s.max_args.min(12) {
assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift");
}
}
}
assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2)));
assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2)));
assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty()));
assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty()));
}
}
+60 -8
View File
@@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R<Value> {
}
}
/// Order of two values for the approximate and binary searches: [`compare`], except that numbers
/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its
/// lookups don't. Observed in Excel 365 with A1 holding
/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because
/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4.
/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode.
fn lookup_order(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or_else(|| compare(a, b)),
_ => compare(a, b),
}
}
fn same_type(a: &Value, b: &Value) -> bool {
matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_)))
}
@@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
continue;
};
let v = hay.get(j)?;
let o = compare(v, x);
let o = lookup_order(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) {
while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) {
k += 1;
}
return Some(k);
@@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option<usize> {
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),
_ if desc => lookup_order(v, x).reverse(),
_ => lookup_order(v, x),
}
}
@@ -134,10 +147,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R<Value> {
return Err(*e);
}
let idx = num(args, 2)?;
if idx < 1.0 {
// NaN too: it would pass `idx < 1.0` and become index 0 (#2163).
if idx.is_nan() || idx < 1.0 {
return Err(CellError::Value);
}
let idx = idx.trunc() as usize - 1;
let idx = (idx.trunc() as usize).saturating_sub(1);
let approx = opt_bool(args, 3, true)?;
let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) };
if idx >= len {
@@ -261,7 +275,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
if !same_type(x, v) {
continue;
}
let o = compare(v, x);
let o = lookup_order(v, x);
let candidate = if match_mode == -1 { o == Ordering::Less } else { o == Ordering::Greater };
if !candidate {
continue;
@@ -269,7 +283,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R<Opt
let better = match best.and_then(|b| hay.get(b)) {
None => true,
Some(bv) => {
let c = compare(v, bv);
let c = lookup_order(v, bv);
if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less }
}
};
@@ -747,6 +761,44 @@ mod tests {
assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]);
}
/// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365.
#[test]
fn lookups_compare_numbers_exactly() {
let e = 0.1 + 0.2;
let one = || rf(col(&[e]));
// Exact modes miss a key one bit away.
is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA);
is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA);
is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA);
close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0);
close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0);
is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA);
// 0 and -0 are still the same key.
close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0);
// Approximate modes: 0.3 sorts below 0.30000000000000004.
is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA);
close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0);
is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA);
is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA);
close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0);
// The bisection stops on 0.3 and doesn't run on into the next entry.
let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4]));
close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0);
close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0);
close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0);
close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0);
let asc = || rf(col(&[0.1, e, 0.5]));
let desc = || rf(col(&[0.5, e, 0.1]));
close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0);
close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0);
is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA);
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0);
close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0);
close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0);
}
#[test]
fn index() {
let t3 = table_rows3();

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