Every edit copies the workbook for its undo step (copy-on-write: sheets
and 64-row bands of cells are shared). A floating picture's bytes
(Image.data) and the VBA project (Workbook.vba_project) were plain
Vec<u8>, so each edit on a sheet with a picture, and every edit in a
workbook with macros, kept another full copy of them, up to the 100
steps of the history. They are Arc<Vec<u8>> now, as cell pictures
already are, with base64 helpers for serde (b64::shared, b64::opt). JSON
and XLSX are unchanged.
Measured on macOS 27 (Apple M3 Max), gridcraft-cli, kernel peak
footprint: a 19 MB picture and 50 cell edits on its sheet, 992 -> 22 MB;
10 edits, 215 -> 21 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/extra.rs:664 (command doc at extra.rs:114)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/data.rs:826
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
do_sort moved cells, formulas, cell pictures, row heights and floating
object anchors through the sort permutation, and remove_duplicates moved
cells and pictures through the compaction, but neither touched
Sheet::comments or Sheet::hyperlinks (keyed by CellRef): a note that
described one record stayed behind and annotated whatever landed on that
row — 'late payment' on a paid invoice — and a note on a row removed by
Remove Duplicates annotated blank cells. Sheet::comments/hyperlinks are
exactly the range-scoped side data that row and column inserts already
remap and edit.clear already clears, so the omission looks unintended.
Both maps now travel through the same permutation the cells use (the
sort collects before re-inserting, so a swap cannot resurrect an entry
from a destination that is also a source; Remove Duplicates compacts the
kept rows and drops the rest).
Regression test: sort_and_remove_duplicates_move_the_rows_notes_and_links
— on main the note and link stay on their old cells; with the fix they
travel with their record.
A cut removed the source's cells and pictures and then re-created the
block's merged areas at the destination and copied its notes there — but
removed nothing from the source, so one merged block became two (an
empty one left behind) and a note ended up on both the old and the new
cell. Excel moves both and leaves the source clean.
The cut now clears the source range's comments, hyperlinks and merges
the same way edit.clear does. A second gap in the same path: paste never
transferred hyperlinks at all, so clearing the source would have lost
them — paste now carries hyperlinks (in What::All and a new
What::Hyperlinks, so edit.pasteSpecial {"what":"hyperlinks"} works
like "comments").
Regression test: a_cut_moves_the_sources_notes_hyperlinks_and_merges —
on main the old merge stays behind and the note is on both cells; with
the fix the merge, note and link all move and the source is clean.
print_titles read both rows and cols as Options and assigned both
unconditionally, so {"rows":"2:2"} set title_cols = None and
{"cols":"A:A"} set title_rows = None: the repeated rows or columns
disappeared from every printed page with no message, and there was no
way to set or clear just one axis. The sibling pageLayout.pageSetup
already distinguishes "key absent" from "key present but empty"; read
both axes the same way — absent leaves the axis alone, empty/null clears
it, anything else that is not a range is an error rather than a silent
wipe.
Regression test: print_titles_keeps_the_axis_it_is_not_given — on main
the rows-only call clears the columns; with the fix each axis is touched
only when named, an explicit empty value still clears, and a bad range
errors.
edit.autoFill capped source and target at 5M cells separately, but fill()
writes one cell per source lane per step, and it never checked the target
held the source. Whole rows into whole columns (`2:3` into `A:A`) filled
every one of 16,384 columns down a million rows: ~17 billion writes, then
a 24-51 GB allocation abort that catch_unwind can't stop.
Excel (observed via COM on a synthetic workbook) refuses AutoFill when the
target doesn't contain the source or holds it away from every corner, and
accepts any corner-sharing target. edit.autoFill now applies that rule
(fill::holds_source_in_corner), and fill() bounds lanes x steps by
MAX_FILL_CELLS as a backstop. The 5M literals in auto_fill and fill_dir
use the shared constant.
Regression test: fill::tests::autofill_refuses_targets_that_do_not_extend_the_source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepare report data before editing so refused reports cannot leave solution cells or the saved model changed. Commit accepted reports with the optional solution and model, retaining array/protection checks and row autofit after sheet insertion.
Closes#1050
Co-Authored-By: OpenAI Codex <noreply@openai.com>
Found while testing crates/engine/src/cmd/insert.rs:477
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A formula read with `cm` (Excel's XLDAPR dynamic-array record in
xl/metadata.xml) now carries `Formula::dynamic`, and the writer keeps the
`cm` and the metadata part for it even when the formula returns one value
and the export shape analysis would not need array evaluation. Before, such
formulas were saved as legacy formulas, so Excel recalculated some of them
with implicit intersection (SUMPRODUCT(IFERROR(range/range,0)) changed
value), and workbooks whose every dynamic formula was scalar lost
xl/metadata.xml altogether.
The mark survives moves, row and column insertion or deletion, sheet and
name renames and LAMBDA call-case fixes; retyping a formula drops it and
the writer decides from the formula's shape, as before. It is serialized
only when set, so saved documents without it are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
selection.set refuses a cell the protection forbids selecting, selection.move steps on to the
next selectable cell, and review.protectSheet keeps the sheet's current selection rights unless
told otherwise. The dialog gains the two select options and insert/delete columns.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
insert.slicer and table.insertSlicer looked for the table named by `table` only on the active
sheet. With another sheet active, the call failed with "Select a cell inside a table first, or
pass `table`" although `table` was passed. Other table commands find a table by name in the
whole workbook (Workbook::table).
The named table is now found on any sheet, and the slicer goes on that table's sheet. `at` is
read on that sheet, so "Sheet1!G2" names the table's own sheet. A name that matches no table
gives "no table named `X`".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`formulas.evaluate` (behind `gridcraft-cli eval` and MCP's `evaluate_formula`) turned any
formula that didn't parse into #NAME?, so `=SUM(1;2)` looked like an unknown function. It now
parses the way `cell.set` does (missing closing parentheses are added) and returns the same
errors: "There's a problem with this formula: expected `,` or `)`, found Semicolon", or the
wrong-argument-count message. An unknown function still evaluates to #NAME?, as in Excel.
home.insertRows {"rows":"3"} and home.insertColumns {"cols":"B"}
failed with "not a range": only spans such as 3:3 or B:B parsed. The
same went for deleting, hiding, sizing and grouping rows and columns.
A single row number or column letter now means that one line.
home.formatCells documents {style: {numFmt?, ...}}, but the style patch
was merged with the style's snake_case field names, so numFmt (and
borders.diagUp/diagDown) were silently ignored. camelCase keys in the
patch are now renamed to the style's own; snake_case keys still work.