cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Merge main (7431646) into Step A

Dami committed bfb1a18
Resolved against main's selection statistics, evaluator, decimal
SUMIF/SUMIFS/SUMPRODUCT, streaming XLSX reader and writer, hidden sheets
and import location:

- Selection statistics are kept per 64 rows on the blocks of the index
  of filled cells, made on first use and marked stale by set, delete and
  recalculation, so they take memory only where there is data; a dense
  index at the new grid would have been 268 M entries.
- The running aggregates are set up in main's evaluate() and survive
  its depth deferral: an extension abandoned part way keeps the rows it
  finished.
- SUMIF, SUMIFS and SUMPRODUCT keep main's terms functions for decimal
  arithmetic, over the sparse reads of masked.go.
- The streaming XLSX reader imports <cols> styles and row styles as line
  formats (a style on A and XFD as the sheet's), takes max-cells, and
  sets column widths without undo steps for the data's columns (at
  least 256) rather than all 16,384; the writer puts column and row
  formats in <cols> and styled <row>s. The excelize reference in the
  differential test does the same.
- A duplicated sheet keeps its line formats.
commit
bfb1a1824065b3ff1696937ca965d4e58f678276
parent
0fdf6cdc2d0f7415dd678aeca1fcb1c06fd9ae13
parent
74316463bdf4f40586ecb4c4c24f2d7a2de0021f
author
Dami <dami.etoile@gmail.com>
116 files changed, +6627 -888
MMakefile2+
MREADME.md3+-
Mcmd/012/main.go2+-
Mdeploy/observability/docker-compose.yml1+
Mdocs/architecture.md28+-
Mdocs/files.md17+-
Mdocs/formulas.md8+-
Mdocs/keys.md5+-
Mdocs/limits.md211+++++++++++---
Mdocs/macros.md15+-
Mdocs/ssh.md6+
Mdocs/testing.md16+-
Me2e/import_test.go39+++
Ae2e/screensheets_test.go80+++++
Me2e/sheets_test.go55++++
Ae2e/testdata/screens/autocomplete-sheet-light.html30++
Ae2e/testdata/screens/autocomplete-sheet-narrow.html16+
Ae2e/testdata/screens/autocomplete-sheet-point.html30++
Ae2e/testdata/screens/autocomplete-sheet.html30++
Ae2e/testdata/screens/import-location-light.html30++
Ae2e/testdata/screens/import-location-narrow.html16+
Ae2e/testdata/screens/import-location.html30++
Ae2e/testdata/screens/import-new-sheets.html30++
Me2e/testdata/screens/menu-freeze.html10+-
Me2e/testdata/screens/palette-narrow.html2+-
Me2e/testdata/screens/palette-search-light.html2+-
Me2e/testdata/screens/palette-search.html2+-
Me2e/testdata/screens/palette-wide.html2+-
Me2e/testdata/screens/palette.html2+-
Ae2e/testdata/screens/sheets-hidden-picker-light.html30++
Ae2e/testdata/screens/sheets-hidden-picker.html30++
Ae2e/testdata/screens/sheets-hidden.html30++
Ae2e/testdata/screens/sheets-menu-hide.html30++
Me2e/testdata/screens/sheets-menu-light.html26+-
Me2e/testdata/screens/sheets-menu.html26+-
Minternal/fileio/fileio.go2+-
Minternal/fileio/snapshot.go4+
Minternal/fileio/stress_test.go98++++++-
Minternal/fileio/wk1.go2+-
Minternal/fileio/xlsx.go447++++++++----------------------
Ainternal/fileio/xlsx_diff_test.go205++++++++++++++
Ainternal/fileio/xlsx_excelize_test.go226+++++++++++++++
Minternal/fileio/xlsx_test.go148++++++++++
Ainternal/fileio/xlsxbook.go297++++++++++++++++++++
Ainternal/fileio/xlsximport.go294++++++++++++++++++++
Minternal/fileio/xlsxlines.go143+++++-----
Ainternal/fileio/xlsxparts_test.go105+++++++
Ainternal/fileio/xlsxpkg.go294++++++++++++++++++++
Ainternal/fileio/xlsxread_test.go273++++++++++++++++++
Ainternal/fileio/xlsxsheet.go422++++++++++++++++++++++++++++
Ainternal/fileio/xlsxshift.go178++++++++++++
Ainternal/fileio/xlsxstyles.go223+++++++++++++++
Ainternal/fileio/xlsxwrite.go361++++++++++++++++++++++++
Minternal/formula/formula_test.go33++
Minternal/formula/parse.go24++
Minternal/jev/key.go96+------
Minternal/serve/serve.go2+-
Minternal/sheet/decimal.go132+++++++--
Minternal/sheet/decimal_test.go17+
Minternal/sheet/eval.go9+-
Ainternal/sheet/evaluate.go183++++++++++++
Ainternal/sheet/evaluate_test.go103+++++++
Minternal/sheet/file.go6+
Minternal/sheet/functions_jev.go1+
Minternal/sheet/functions_jev_test.go42+++
Ainternal/sheet/hidden.go84++++++
Ainternal/sheet/hidden_test.go90++++++
Minternal/sheet/historysheets.go15+-
Ainternal/sheet/insertbook.go117++++++++
Ainternal/sheet/insertbook_test.go112++++++++
Minternal/sheet/load.go8+
Minternal/sheet/nav.go40---
Minternal/sheet/occupancy.go5+-
Minternal/sheet/recalc.go87+-----
Minternal/sheet/refs.go20+
Minternal/sheet/remote.go42+++-
Ainternal/sheet/remotekey.go95++++++
Minternal/sheet/sheet.go7+-
Minternal/sheet/sheet_test.go10+
Ainternal/sheet/stats.go153++++++++++
Ainternal/sheet/stats_test.go118++++++++
Minternal/sheet/store.go21+-
Minternal/sheet/stress_test.go4+-
Minternal/sheet/workbook.go35+-
Minternal/ui/assist.go36++-
Ainternal/ui/assistsheets.go131+++++++++
Ainternal/ui/assistsheets_test.go109+++++++
Minternal/ui/edit_test.go13+
Minternal/ui/entry.go4+-
Minternal/ui/find.go6+-
Minternal/ui/findview.go4+-
Minternal/ui/formula/formula.go21+-
Minternal/ui/formula/formula_test.go7+
Ainternal/ui/hidden.go113++++++++
Ainternal/ui/hidden_test.go144++++++++++
Ainternal/ui/importplace.go121++++++++
Ainternal/ui/importplace_test.go117++++++++
Minternal/ui/jev.go48+++-
Minternal/ui/jev_test.go69+++++
Minternal/ui/macrohostnav.go30++
Minternal/ui/macrorec.go3+
Minternal/ui/menubar.go3+-
Minternal/ui/model.go12+
Minternal/ui/names.go6+
Minternal/ui/palette.go36++-
Minternal/ui/pivotactions.go4+
Minternal/ui/selection.go7+-
Minternal/ui/serve_test.go2+-
Minternal/ui/stress_test.go12+-
Minternal/ui/tabs.go41++-
Minternal/ui/tabs_test.go2+-
Minternal/ui/tabstrip.go21+-
Minternal/ui/trace.go3+
Minternal/ui/transfer.go51++--
Minternal/ui/transfer_test.go12+-
Mscripts/obs.sh2+-
M Makefile +2 -0
M README.md +2 -1
M cmd/012/main.go +1 -1
M deploy/observability/docker-compose.yml +1 -0
M docs/architecture.md +19 -9
@@ -23,7 +23,7 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
2323 rowtext laying out a row of cell text across the columns on screen2424 formula reading the formula being typed (F4, the word and call at the caret)2525 e2e/ end-to-end tests through libghostty (separate module, cgo)26-oracle/ differential tests against excelize (separate module)26+oracle/ differential tests against excelize's calculation (separate module)2727 ```2828 2929 ## The engine
@@ -130,8 +130,11 @@ move without touching callers:
130130 aggregates read ranges.131131 - **Files.** A streaming or columnar `.012` format replaces `file.go`'s132132 whole-document JSON; the format is already separate from the store.133-- **Depth limits.** A cap on nesting goes in `internal/formula`'s parser,134- so a pathological formula fails to parse before anything evaluates it.133+- **Depth limits.** `internal/formula`'s parser caps nesting at134+ `formula.MaxDepth` (1024 levels), so a pathological formula fails to135+ parse before anything evaluates it; evaluation puts off cells past136+ 65,536 levels of a chain rather than recursing further137+ (`evaluate.go`).135138 - **Functions.** The function library (values, evaluation, `FuncDef` and136139 the `functions_*.go` tables, about a third of `sheet`) is the next137140 boundary: it needs cells only through `lookup`. It stays in `sheet`
@@ -212,12 +215,19 @@ falls back to a formula's cached value when it can't be translated, and
212215 counts what didn't fit. They stream: CSV and TSV are read record by213216 record, Parquet a batch of rows at a time, SQLite a row at a time, and214217 Parquet files and SQLite tables stop at the sheet's last row, taking the215-number of rows left out from the file. XLSX goes through excelize, which216-holds the worksheet in memory while its rows are read, so it is the one217-format read twice; its files are small next to the others' and it's218-bounded by the sheet's size. Exporters write a `Snapshot` taken on the UI219-goroutine, in the background, through one atomic write-then-rename; CSV220-and TSV are written a row at a time. One package suits formats that share218+number of rows left out from the file. XLSX is read by 012's own219+SpreadsheetML reader on `archive/zip` and `encoding/xml`: the workbook,220+shared strings (kept end to end in one buffer) and styles first, then221+each worksheet a token at a time, a row at a time, expanding shared222+formulas only for the cells kept. The file is untrusted, so the reader223+caps what it can be made to do (`xlsxLimits`: uncompressed bytes per224+part and in all, compression ratio, zip entries, XML nesting and token225+size, shared strings, styles, sheets, and the text shared formulas226+expand to) and refuses unsafe part names and references past Excel's227+edges; `FuzzReadXLSX` and `FuzzReadXLSXParts` fuzz it. XLSX is written228+with the same packages, a worksheet part streamed per sheet. Exporters229+write a `Snapshot` taken on the UI goroutine, in the background, through230+one atomic write-then-rename; CSV and TSV are written a row at a time. One package suits formats that share221231 this much (the builder, number formats, serial dates and Excel formula222232 translation); a format that grew its own dependencies would move to a223233 subpackage behind the same table row.
M docs/files.md +16 -1
@@ -7,10 +7,19 @@ through File > Download. Imports run in the background with a progress
77 bar; Esc cancels. Saving an imported sheet asks whether to save it as a88 `.012` file or download it back in its format.99 10+File > Import asks where the data goes, as Sheets' Import location does11+(a new, empty spreadsheet is simply replaced):12+13+| Location | Does |14+|---|---|15+| Insert new sheet(s) | Adds the file's sheets after the others: every sheet of an `.xlsx`, named after the file for other formats. A name already taken gets a number (`Sales 2`) and the file's formulas follow it; named ranges come along unless their name is taken. One undo step, and the spreadsheet stays the file you're editing |16+| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. One undo step. Not offered for `.xlsx`, which holds several sheets |17+| Replace spreadsheet | Opens the file instead, as File > Open and the command line do, asking first when there are unsaved changes |18+1019 | Format | Import | Download |1120 |---|---|---|1221 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |13-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles. Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed | Every sheet, the same, with formulas in Excel's syntax and their results cached, and column and row formats as column and row styles. JEV functions and `#AND#` save as values, and so do pivot tables: Excel gets the results, not a pivot |22+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, and column and row formats as column and row styles. JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference) or a sheet whose name Excel can't take as is (renamed in the file, e.g. `Plan (2)`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |1423 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |1524 | Parquet | Every column, with dates and timestamps; lists joined with commas | |1625 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |
@@ -116,6 +125,12 @@ arithmetic, the sheet shown when saved) stay at the top:
116125 117126 Older builds refuse version 4 files rather than lose sheets.118127 128+A hidden sheet (Hide sheet on its tab) has `"hidden": true` after its129+name. It needs no version bump: builds without hidden sheets ignore the130+field and show the sheet. A file whose sheets are all hidden opens with131+the first one shown. XLSX downloads write hidden sheets hidden, and132+sheets hidden in Excel import hidden.133+119134 ## Pivot tables120135 121136 A workbook with a pivot table is version 5: version 4 with a `pivot`
M docs/formulas.md +6 -2
@@ -52,7 +52,11 @@ caret on the problem and says what's wrong on the context line, e.g.
5252 - After an operator or `(`, arrow keys (or a click) pick a cell and insert5353 its reference; Shift+arrows (or a drag) pick a range. Keep typing to go on.5454 - While you type a name, suggestions drop down: named ranges first, then55- functions with their arguments. Tab or Enter inserts `NAME(`.55+ the other sheets, then functions with their arguments. Tab or Enter56+ inserts `NAME(`, or a sheet with its `!`, quoted when it needs to be57+ (`=Su` offers `Summary!`; `=Q3` or `='Q3` offers `'Q3 plan'!`). An arrow58+ after it points into that sheet, as clicking its tab does. Hidden59+ sheets aren't offered, though formulas still read them.5660 - Inside a function's parentheses the context line shows its signature with5761 the current argument marked, e.g. `SUMIF(range, criterion, [sum_range])`.5862 - Alt+, and Alt+. trace precedents and dependents: the cells a formula reads
@@ -96,7 +100,7 @@ file and can be undone.
96100 97101 | Computed in decimal | Stays binary |98102 |---|---|99-| `+ - * /` and postfix `%`; `SUM`, `AVERAGE`; `ROUND`, `ROUNDUP`, `ROUNDDOWN`, `TRUNC` | `^`, `SQRT`, `PRODUCT`, `SUMIF`, `SUMPRODUCT`, statistics, finance, dates and everything else |103+| `+ - * /` and postfix `%`; `SUM`, `AVERAGE`, `PRODUCT`, `SUMIF`, `SUMIFS`, `SUMPRODUCT`; `ROUND`, `ROUNDUP`, `ROUNDDOWN`, `TRUNC` | `^`, `SQRT`, statistics, finance, dates and everything else |100104 101105 Values are still stored as doubles. Each decimal step reads its inputs as the102106 shortest decimal that round-trips (what the cell shows), computes exactly
M docs/keys.md +3 -2
@@ -31,7 +31,7 @@ the app uses, so it never drifts from what the keys do.
3131 | Ctrl+Alt+= / Ctrl+Alt+- | Insert rows above / delete the selected rows (columns when whole columns are selected) |3232 | Ctrl+Enter while typing | Enter the same entry in every selected cell |3333 | F4 while typing a formula | Cycle the reference at the caret through A1, $A$1, A$1, $A1 |34-| Typing a function or range name | Suggestions drop down: Up/Down pick, Tab or Enter insert, Esc hides them; inside a function's parentheses the context line shows its arguments with the current one marked |34+| Typing a function, range or sheet name | Suggestions drop down: Up/Down pick, Tab or Enter insert (a sheet as `Summary!`, then arrows point into it), Esc hides them; inside a function's parentheses the context line shows its arguments with the current one marked |3535 | Alt+, / Alt+. | Trace precedents / dependents: highlight the cells a formula reads, or the formulas that read the cell, and jump to the first; press again for the next, Esc to go back (Excel's Ctrl+[ and Ctrl+], which terminals send as Esc) |3636 | Data > Named ranges | Name ranges for formulas (`=SUM(Sales)`): Enter goes to one, F2 renames or repoints it, Ctrl+D deletes it; Data > Define named range names the selection |3737 | Ctrl+Shift+1 ... 6 | Number, time, date, currency, percent, scientific format |
@@ -43,6 +43,7 @@ the app uses, so it never drifts from what the keys do.
4343 | Ctrl+PgDn / Ctrl+PgUp, Alt+Right / Alt+Left | Next / previous sheet; while typing a formula, point into it to insert `Sheet2!A1` |4444 | Shift+F11 | New sheet (also Insert > Sheet) |4545 | Alt+Shift+K | Go to a sheet by name |46+| Right-click a tab > Hide sheet, View > Hidden sheets | Hide a sheet (formulas still read it; next, previous and the tabs skip it), and list the hidden ones to show one again |4647 | Alt+F, Alt+E, Alt+V, Alt+I, Alt+O, Alt+H, F10, click a title | Open a menu (arrows move, Enter runs, Esc closes) |4748 | Ctrl+K, Alt+/, Ctrl+Shift+P | Search the menus: find and run any command |4849 | Right-click, Shift+F10 | Cell, column or row menu |
@@ -125,7 +126,7 @@ bar in terminals that support it (OSC 22).
125126 126127 The sheet tabs at the left of the status line take the mouse too: click a127128 tab to show its sheet, double-click to rename it, right-click for its menu128-(rename, duplicate, delete, move left or right), drag it onto another tab to129+(rename, duplicate, delete, hide, move left or right), drag it onto another tab to129130 move it there, and click `+` to add a sheet. When the tabs don't all fit,130131 `‹` and `›` step through them. While typing a formula, clicking a tab points131132 into that sheet, as in Sheets.
M docs/limits.md +161 -50
@@ -26,14 +26,14 @@ it lags, and past a second it stalls.
2626 | Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms); 1000 SUMs over a full column: 0.7 ms; 8192 running totals: 2.7 ms | | | About 20 ns per cell read: two map lookups |2727 | Full recalc | Any sheet: under 40 ms for 213 k numbers; 1000 full-column SUMs 1.1 ms; 8192 running totals 2.6 ms | | | Same as above |2828 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |29-| Selection statistics | Any selection, once computed (cached) | Extending a selection over 2 M cells: 30 ms per key | | Summing 2 M map entries per change |30-| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (two million cells in about 1 s) | XLSX with formulas: 200 k cells/s | Data past `max-cells` or the grid (dropped, with a note) | Building cells one at a time; XLSX formula translation |29+| Selection statistics | Any selection: extending one over all 2 M cells costs 0.5 ms a key | | | Per column with data, 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |30+| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (two million cells in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |3131 | Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |32-| JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |33-| Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |32+| JEV | 4000 JEV cells: 4 us of CPU per answer, 15 ms to answer them all | | | An answer recalculates the cells that asked it; answers within a frame recalculate together |33+| Formula depth | 1000 nested parentheses or IFs: under 0.5 ms; chains of formulas through every cell of a sheet | | More than 1024 levels of nesting in one formula: a parse error | Recursive parser; evaluation puts off cells past 65,536 levels |3434 | Macros | Replaying 1000 recorded actions: 2.2 ms, one undo step; a script's call to the sheet: about 1.4 us | | Scripts past 10 M Starlark steps: stopped, with the line | One message per call to the sheet, served in batches on the UI goroutine |3535 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |36-| SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 16.6 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 60 fps pacing; per session, the terminal's cell buffers |36+| SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |3737 | Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 1 to 4 ms | | Results past the grid (the pivot shows #REF!) | Reading each source cell of its fields: a map lookup each |3838 3939 ## Sheet size
@@ -51,9 +51,14 @@ cells, as they always did; only the grid is no longer a cost.
5151 | Live heap | 64 MB | 602 MB (about 300 B per number cell) |5252 | Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |5353 | Open `.012` | 134 ms (116 MB allocated) | 2.08 s (1.3 GB allocated) |54-| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 87 / 40 ms | 0.71 / 0.66 / 0.91 / 0.72 s |54+| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 62 / 40 ms | 0.71 / 0.66 / 0.81 / 0.72 s |5555 | Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |5656 57+XLSX exports are written by 012 with archive/zip since excelize went:58+measured back to back with the excelize writer, 8192 x 26 went from 33559+to 62 ms and 379 to 1.9 MB allocated, 8192 x 256 from 0.97 to 0.81 s60+(mostly compression) and 259 to 17 MB allocated.61+5762 Heap per non-blank cell after loading (`BenchmarkMemory`,5863 `BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and5964 reference lists), 200-character text 790 B, imported CSV 275 to 300 B.
@@ -80,11 +85,15 @@ Every volatile formula is recomputed on every change, as in Sheets, so
8085 8192 volatile cells add about 1.2 ms to every edit anywhere.8186 8287 Through a real terminal (`make stress-e2e`: 200 x 60, 8192 x 26 imported,83-200 SUMs over column A): arrow keys p50 16.7 ms, p95 17.5 ms; an entry84-that recalculates the 200 SUMs p50 57 ms, p95 72 ms. The arrow floor is85-Bubble Tea's renderer, which draws at most 60 frames a second, so a key86-waits up to 16 ms for the next frame; 012's own share of an arrow key is87-0.2 to 1 ms.88+200 SUMs over column A): arrow keys p50 8.3 ms, p95 10.2 ms; an entry89+that recalculates the 200 SUMs p50 62 to 70 ms, p95 67 to 75 ms. The90+arrow floor is Bubble Tea's renderer, which draws at most 120 frames a91+second (`ui.FrameRate`, its maximum; the default is 60), so a key waits92+up to 8 ms for the next frame; 012's own share of an arrow key is 0.293+to 1 ms. At 60 frames a second the same arrows took p50 16.6 ms, p9594+18.4 ms. The renderer writes a frame only when the view changed, so an95+idle 012 costs the same at either rate: 0.6 to 2.2 ms of CPU a second96+on an empty sheet over 30 s, within the noise of measuring it.8897 8998 ## Rendering9099
@@ -113,9 +122,29 @@ when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and
113122 is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60; Page Down 1.26 ms.114123 115124 With the whole of an 8192 x 256 sheet selected, the status line's Sum,116-Avg and Count cover 2.1 M cells: 29 ms to compute, then cached until a117-cell changes, so frames with a standing selection cost 0.3 ms; each key118-that changes the selection pays the 29 ms again.125+Avg and Count cover 2.1 M cells. On a sheet of more than 16,384 cells,126+a selection of more than 4096 is summed from an index of each column's127+sum, count and count of numbers in blocks of 64 rows (`internal/sheet/stats.go`):128+the whole blocks it covers, plus the rows at its ends a cell at a time.129+The index is built on first use, in one pass over the cells (the 30 ms130+the whole sum used to take every time), and a stored, deleted or131+recalculated cell marks its block to be summed again when a selection132+next covers it. Sums are recomputed, never adjusted, so they don't133+drift. The result is still cached until a cell changes. Each key that134+changes a selection over the whole sheet, through to its frame135+(`BenchmarkKeystroke`, 3 runs each, same machine):136+137+| Key, 8192 x 256 numbers | 80 x 24 before | after | 200 x 60 before | after |138+|---|---|---|---|---|139+| Shift+Up and Shift+Down with the data selected (`extend-data`) | 31 to 44 ms | 0.53 ms | 32 to 46 ms | 2.1 ms |140+| Ctrl+Shift+Down and Ctrl+Shift+Up over the first row (`extend-edge`) | 16 to 23 ms | 0.43 ms | 18 to 24 ms | 1.7 ms |141+| Ctrl+A, the selection standing (`select-data`) | 0.30 ms | 0.30 ms | 1.8 ms | 1.8 ms |142+143+A CPU profile of `extend-data` before put 54% of the time in144+`RangeStats` walking the map; the rest is building the benchmark's sheet.145+The index costs 0.5 MB per sheet that has one (32,768 blocks of 16 B),146+nothing on sheets that never had a large selection, and a nil check per147+stored or recalculated cell.119148 120149 ## Imports121150
@@ -128,7 +157,7 @@ Real, openly licensed datasets fetched by `scripts/stress-data.sh`
128157 | Airport codes (CSV) | 86,134 x 13, first 8192 rows kept | 83 ms | 107 MB/s | 274 B |129158 | NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |130159 | Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |131-| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas | 19 ms | 205 k cells/s | 358 B |160+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.7 ms | 395 k cells/s | 425 B |132161 | Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |133162 | Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |134163
@@ -139,9 +168,36 @@ CSV, TSV and SQLite queries are read to their end to count it, without
139168 keeping it. The table above is from before the grid grew, when files140169 were cut at row 8192; now the airport codes (855 k cells, 0.80 s) and141170 OWID energy (1.04 M cells, 0.95 s) come in whole, at the same rate of142-about a million cells a second. Every importer but XLSX143-streams, so memory follows the sheet, not the file; excelize holds an144-XLSX worksheet in memory while its rows are read.171+about a million cells a second. Every importer streams,172+so memory follows the sheet, not the file. XLSX used to go through173+excelize, which held each worksheet in memory while its rows were read;174+012's own reader streams it a token at a time, keeping only the shared175+strings (in one buffer, 4 bytes a string besides the text), the cell176+formats, and the first cell of each shared formula.177+178+XLSX imports of full sheets written by 012's exporter179+(`BenchmarkImportXLSX`) and the POI file, before (excelize) and after180+(012's reader), on an Apple M5 Pro. Peak heap is sampled while181+importing, per cell kept, and includes garbage not yet collected:182+183+| Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |184+|---|---|---|---|---|---|185+| 8192 x 26 numbers (213 k cells) | 480 -> 199 ms | 491 -> 170 MB | 8.4 -> 3.3 M | 304 -> 290 B | 1365 -> 589 B |186+| 8192 x 26 table, text and numbers | 642 -> 296 ms | 644 -> 234 MB | 14.4 -> 7.4 M | 297 -> 297 B | 1432 -> 722 B |187+| 8192 formulas (`=A1+1` down a column) | 33 -> 13.9 ms | 38 -> 17 MB | 718 -> 321 k | 752 -> 746 B | 4790 -> 2188 B |188+| Apache POI formula tests | 21.1 -> 9.7 ms | 22 -> 9.7 MB | 373 -> 179 k | 429 -> 425 B | 2343 -> 1288 B |189+190+The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),191+with a message naming the limit: 10,000 files in the zip; 1 GB192+uncompressed in one part and 2 GB in all, counted as the bytes come out193+whatever the zip's headers say; a part compressed more than 250 to 1194+once past 16 MB (a zip bomb); XML nested more than 256 deep or a single195+tag or text over 32 MB; 16.7 M shared strings; 65,536 cell formats,196+fonts, number formats or names; 4096 sheets; 256 MB of text from197+expanding shared formulas; rows past 1,048,576, columns past XFD and198+rows of more than 16,384 cells; part names that are absolute, climb with199+`..` or hold a backslash. encoding/xml expands no external or declared200+entities, so entity bombs fail as unknown entities.145201 146202 ## Undo147203
@@ -171,20 +227,65 @@ BigUndo's 3%; everything else is unchanged.
171227 ## JEV172228 173229 Each JEV formula is volatile: every recalculation looks its question up174-in the cache, and every answer that arrives recalculates all JEV175-cells. With a fake client answering instantly (`BenchmarkJEV`), the CPU176-per answer grows with the number of JEV cells:177-178-| JEV cells | Per answer | All answers |179-|---|---|---|180-| 100 | 26 us | 2.7 ms |181-| 1000 | 261 us | 0.26 s |182-| 4000 | 1.3 ms | 5.1 s |230+in the cache. A formula shown Loading… is noted against the question it231+waits for (by `RemoteCall.Key`), and an answer recalculates only the232+formulas waiting for it and what reads them233+(`Workbook.RecalcAnswered`). Answers are stored as they arrive; the234+first since the last recalculation schedules one a frame later235+(`ui.FrameInterval`), which covers every answer stored meanwhile, so236+answers arriving together recalculate once. With a fake client237+answering instantly (`BenchmarkJEV`, 3 runs each, same machine; the238+benchmark doesn't wait the frame, so answers queued together, up to 8,239+recalculate together):240+241+| JEV cells | Per answer before | after | All answers before | after |242+|---|---|---|---|---|243+| 100 | 29 us | 3.5 us | 2.9 ms | 0.35 ms |244+| 1000 | 270 us | 3.6 us | 0.27 s | 3.6 ms |245+| 4000 | 1.2 ms | 3.8 us | 4.9 s | 15 ms |183246 184247 Against the real service the network dominates: at most 8 questions are185-in flight, so 4000 questions at 300 ms each take 2.5 minutes, of which186-5 s is recalculation spread over that time. Each answer blocks the UI187-for its recalculation (1.3 ms at 4000 cells).248+in flight, so 4000 questions at 300 ms each take 2.5 minutes, and the249+recalculation is now a few microseconds per answer whatever the number250+of JEV cells. Other changes still recalculate every JEV cell, as every251+volatile formula, looking the answers up in the cache.252+253+## Formula depth254+255+The parser (`internal/formula`) counts nesting as it recurses:256+parentheses, function calls and prefix operators each open a level. Past257+`formula.MaxDepth`, 1024 levels, a formula fails to parse with "Formula258+is nested too deeply (more than 1024 levels)", shown on the context line259+with the caret at the level past the cap, as other parse errors are.260+Excel allows 64 nested functions; 1024 is far beyond a formula written261+by hand, and bounds the recursion of everything that walks a formula262+(the printer, reference rewriting, the evaluator within one formula). A263+`.012` file holding a deeper formula (none could have been written by264+hand) fails to open with that message and the cell; imports keep such a265+formula's text or cached value, as for any formula they can't read.266+267+Evaluation recurses once per link of a chain of formulas, which no268+parser cap bounds: a chain through all 2.1 M cells of a sheet used to269+overflow the goroutine's 1 GB stack and kill the program. Evaluation now270+counts its depth in cells and operators; a cell reached past 65,536271+levels is put off: the evaluation in progress is abandoned (its cells go272+back to dirty), the cell is evaluated from the top of the stack, and the273+abandoned evaluation is retried and finds it done. Values are exactly as274+before, a cycle longer than the limit is still a cycle, and the stack275+stays under 32 MB (`TestFullSheetChain`, run with276+`SHEET_FULL_CHAIN=1`, checks it under a 64 MB limit: 4 s for 2.1 M277+cells, building included).278+279+Same machine, 8 runs each with before and after interleaved, other280+work running (differences under 10% are noise):281+282+| Benchmark | Before | After |283+|---|---|---|284+| Parse (`BenchmarkParse`) | 2.36 us | 2.41 us |285+| 1000 nested parentheses / IFs, parse and evaluate | 195 / 425 us | 203 / 438 us |286+| 10,000 nested parentheses / IFs | 1.8 / 4.4 ms, accepted | 1.1 / 2.0 ms to refuse |287+| RecalcAll chain of 8192 / dense 8192 x 26 | 1.33 / 50 ms | 1.28 to 1.38 / 41 to 51 ms |288+| Edit chain of 8192 / 8192 volatiles | 2.36 / 1.68 ms | 2.34 / 1.72 ms |188289 189290 ## Several sheets190291
@@ -227,7 +328,7 @@ session press an arrow key at once, 200 times, timing each key from the
227328 client's write to the first bytes of its frame arriving. The clients228329 run in the same process, so the heap and CPU figures include their side229330 of the connections and are upper bounds for the server. Medians of 3230-runs, `-benchtime 200x`:331+runs, `-benchtime 200x`, at 60 frames a second:231332 232333 | Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |233334 |---|---|---|---|
@@ -236,8 +337,18 @@ runs, `-benchtime 200x`:
236337 | 50, new sheet | 1.30 MiB | 9.2 / 16.3 / 19.2 ms | 0.53 ms |237338 | 50, 1000 x 26 numbers | 8.5 MiB | 9.4 / 16.7 / 20.5 ms | 0.69 ms |238339 340+Sessions now ask for 120 frames a second, as the local app does. One341+run each, back to back, before and after:342+343+| Sessions, sheet | p50 / p95 / max at 60 fps | at 120 fps | CPU per frame, 60 / 120 fps |344+|---|---|---|---|345+| 10, new sheet | 10.8 / 16.6 / 16.8 ms | 5.1 / 8.3 / 9.6 ms | 0.78 / 0.67 ms |346+| 10, 1000 x 26 numbers | 9.7 / 16.6 / 20.7 ms | 4.3 / 8.3 / 8.5 ms | 1.41 / 0.71 ms |347+| 50, new sheet | 9.3 / 16.3 / 18.5 ms | 5.0 / 8.7 / 17.8 ms | 0.58 / 0.53 ms |348+| 50, 1000 x 26 numbers | 9.5 / 16.7 / 19.7 ms | 5.1 / 8.9 / 12.8 ms | 0.71 / 0.66 ms |349+239350 Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame240-pacing (at most one frame every 16.7 ms, so a key waits half a frame on351+pacing (at most one frame every 8.3 ms, so a key waits half a frame on241352 average), not load. A heap profile with 50 sessions open242353 (`SERVE_HEAP_PROFILE=file`) puts three quarters of a session's heap in243354 Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the
@@ -247,7 +358,9 @@ top come about 300 B per cell of the sheets a session has open;
247358 sessions opening the same file each hold a copy. CPU per frame covers both ends of the connection (the248359 encryption twice, the client reading the frame); at 0.7 ms, 50 sessions249360 typing continuously at 60 frames a second would keep about two cores250-busy.361+busy, and at 120 about four: frames are drawn only when a session's362+screen changes, so the rate is the most keys can cause, not a cost of363+being connected.251364 252365 ## Pivot tables253366
@@ -363,6 +476,10 @@ Each with its benchmark before and after, in the commit that fixed it.
363476 | Aggregates read every cell of every range, 1000 identical SUMs a thousand times, running totals O(n^2) | Fan-in and running-total edits | Edit/fanin 176 ms, running 0.73 s | 0.72 ms, 2.7 ms (running aggregates shared per recalculation) |364477 | Range users bucketed by a fixed array of 256 columns, scanned whole per changed cell | 16,384 columns; whole-row ranges in every bucket | | Interval trees per column, wide ranges in one tree |365478 | A range read visited every address, blank or not | A whole column is a million addresses | | Occupancy index: only stored cells are visited |479+| Changing a selection summed every cell in it | CPU profile of Shift+Up over the whole sheet: `RangeStats` 54% | extend-data 80 x 24: 31 to 44 ms | 0.53 ms |480+| Every JEV answer recalculated every JEV cell | Per-answer cost grew with the number of JEV cells | JEV/4000 4.9 s | 15 ms |481+| Bubble Tea drew at most 60 frames a second | Arrow keys through a terminal waited for the next frame | p50 16.6 ms, p95 18.4 ms | 8.3 ms, 10.2 ms |482+| Lazy evaluation recursed once per link of a chain | A chain through all 2 M cells of a sheet overflowed the 1 GB stack and killed the program | crash | 4 s to build and evaluate, under 32 MB of stack |366483 367484 ## What would raise the bounds368485
@@ -391,26 +508,20 @@ is done: see Step A.)
391508 allocation tenfold; a columnar or gzip-compressed variant would cut392509 the size about fourfold. The format is `internal/sheet/file.go`, apart393510 from the cell store.394-3. **Incremental selection statistics** (S, 2 days). Keep per-column395- sums and counts, updated in `place` and after recalc, and compute a396- selection's Sum and Count from column totals minus the rows outside397- it; extending a whole-sheet selection would drop from 29 ms to well398- under 1 ms.399-4. **JEV recalc by question** (S to M, 3 days). Index JEV cells by400- question key and recalculate only the cells whose question was401- answered (and their dependents), and coalesce answers that arrive in402- the same frame: answering 4000 questions would cost 4000 small403- recalcs instead of 4000 full ones.511+3. **Incremental selection statistics.** Done: per-column block512+ aggregates, marked stale as cells change (see [Rendering](#rendering));513+ extending a whole-sheet selection went from 31 to 44 ms to 0.53 ms.514+4. **JEV recalc by question.** Done: an answer recalculates the cells515+ waiting for it, and answers within a frame recalculate together (see516+ [JEV](#jev)); answering 4000 questions went from 4.9 s of CPU to517+ 15 ms.404518 5. **Smaller undo steps** (S, 1 day). The history is capped by the405519 memory its before-images hold; storing formatting-only changes as406520 diffs rather than whole cells would let it keep more of them.407-6. **Frame rate** (S, hours). Bubble Tea draws at most 60 frames a408- second; asking for 120 would halve the key-to-screen floor from about409- 16 ms to 8 ms at the cost of more redraws.410-7. **Recursion limits** (S, hours). The parser (`internal/formula`) and411- the evaluator recurse without a limit; a depth cap (Excel allows 64412- nested functions) would turn a pathological file into an error rather413- than a deep stack.521+6. **Frame rate.** Done: 120 frames a second, Bubble Tea's maximum,522+ locally and over SSH; arrow keys through a terminal went from p50523+ 16.6 ms to 8.3 ms, with no more CPU when idle.524+7. **Recursion limits.** Done: see [Formula depth](#formula-depth).414525 415526 ## Measuring416527
M docs/macros.md +8 -7
@@ -182,9 +182,9 @@ is already in the formulas that use it.
182182 183183 | Function | Does |184184 |---|---|185-| `sheets()` | The sheet names, in tab order |185+| `sheets()` | The sheet names, in tab order, hidden ones included |186186 | `active_sheet()` | The name of the sheet shown |187-| `activate_sheet(name)` | Show a sheet |187+| `activate_sheet(name)` | Show a sheet (not a hidden one: `run("sheet.unhide", answer=name)` shows it again) |188188 | `add_sheet(name=None)` | Add a sheet after the one shown and show it; returns its name |189189 | `move_sheet(position)` | Move the sheet shown to a position, counting from 1 |190190 | `set_width(cols, width)` | Set column widths, 1 to 240: `set_width("B", 14)`, `set_width("B:D", 8)` |
@@ -194,10 +194,11 @@ is already in the formulas that use it.
194194 `run(id, answer=None)` runs any command from the menus or palette by its195195 id, on the selection, as if picked. A command that asks something (a196196 width, a name, a confirmation) needs `answer`: the text you'd type, a range197-for a range question, or the key of a choice (`"enter"`, `"d"`) or its198-label. Commands that open a dialog or picker (Sort range, Insert chart,199-Named ranges) can't run in a script; nor can files, menus, help, undo or200-macros. The ids are those in the recorded scripts; a few:197+for a range question, the key of a choice (`"enter"`, `"d"`) or its198+label, or the item picked from a list (View > Hidden sheets takes the199+sheet's name). Commands that open a dialog or another picker (Sort200+range, Insert chart, Named ranges) can't run in a script; nor can files,201+menus, help, undo or macros. The ids are those in the recorded scripts; a few:201202 202203 | Id | Command |203204 |---|---|
@@ -210,7 +211,7 @@ macros. The ids are those in the recorded scripts; a few:
210211 | `column.width` (answer: the width), `column.reset` | Column widths |211212 | `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |212213 | `data.define_name` (answer: the name) | Name the selection |213-| `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks) | Sheets |214+| `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks), `sheet.hide`, `sheet.unhide` (answer: the sheet's name) | Sheets |214215 | `view.freeze_rows1`, `view.freeze_cols1`, `view.freeze_rows0`, ... | Frozen panes |215216 216217 Record a macro that uses a command to see its id.
M docs/ssh.md +6 -0
M docs/testing.md +15 -1
@@ -3,7 +3,7 @@
33 ```sh44 make lint # go vet, cognitive complexity at most 25, Go files at most 500 lines55 make test # engine, file formats and UI unit tests6-make fuzz # fuzz the formula parser and the CSV and .wk1 readers6+make fuzz # fuzz the formula parser and the CSV, .wk1 and XLSX readers77 make oracle # compare formulas and number formats with excelize88 make e2e # run the real binary in a terminal emulator (needs Zig and pkg-config)99 make screens # rewrite the golden screens and build the review gallery
@@ -63,6 +63,20 @@ formatted values with 012's. Known differences between Sheets and Excel,
6363 and places where excelize departs from Excel, are listed as skips with a6464 reason.6565 66+## The XLSX differential test67+68+`TestXLSXDifferential` (`internal/fileio/xlsx_diff_test.go`) imports a69+corpus of workbooks with 012's own XLSX reader and with the excelize70+importer it replaced (kept in `xlsx_excelize_test.go`), and compares71+every sheet as saved, the widths, names, sheet shown, rows and notes.72+The corpus is workbooks written by the test (hand-written SpreadsheetML,73+excelize and 012's exporter), the stress datasets' XLSX files when74+fetched (`STRESS_DIR`), and any directories in `XLSX_CORPUS`, such as75+excelize's own `test/` files in the module cache. Where excelize reads a76+file wrongly (formulas spread across merged cells, spaces dropped from77+shared formulas) the comparison leaves the difference out, with the78+reason next to it.79+6680 ## Live checks6781 6882 `JEV_LIVE_TEST=1 go test ./internal/jev -run TestLive` asks the real JEV
M e2e/import_test.go +39 -0
@@ -189,3 +189,42 @@ func TestImportProgressAndCancel(t *testing.T) {
189189 s.waitFor("Import of big.csv cancelled")190190 s.waitFor("READY")191191 }192+193+// File > Import on a spreadsheet with something in it asks where the194+// file goes: new sheets (every sheet of an .xlsx), undone as one step,195+// or in place of the sheet shown.196+func TestImportLocation(t *testing.T) {197+ dir := t.TempDir()198+ importDir(t, dir)199+ s := start(t, dir)200+ s.keys("Notes", "<enter>")201+ openImportPicker(s)202+ s.keys("q3", "<enter>")203+ s.waitFor("Import location")204+ scr := s.screen()205+ if !strings.Contains(scr, "Insert new sheets") || !strings.Contains(scr, "Replace spreadsheet") || strings.Contains(scr, "Replace current sheet") {206+ t.Errorf("locations for an .xlsx:\n%s", scr)207+ }208+ s.keys("<enter>")209+ s.waitFor("Imported q3.xlsx as Q3 and Q4")210+ if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 Q3 Q4 +") {211+ t.Errorf("tabs %q", l)212+ }213+ s.waitFor("$1,200.50")214+ s.keys("<ctrl+z>")215+ s.waitFor("Undid: import q3.xlsx")216+ if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 +") {217+ t.Errorf("tabs after undo %q", l)218+ }219+220+ openImportPicker(s)221+ s.keys("tsv", "<enter>")222+ s.waitFor("Import location")223+ s.keys("current", "<enter>")224+ s.waitFor("Imported budget.tsv into Sheet1 (2 rows)")225+ s.waitForBar("A1", "Rent")226+ s.keys("<ctrl+z>")227+ s.waitFor("Undid: import budget.tsv")228+ s.keys("<ctrl+home>")229+ s.waitForBar("A1", "Notes")230+}
A e2e/screensheets_test.go +80 -0
@@ -0,0 +1,80 @@
1+package e2e2+3+import ghostty "go.mitchellh.com/libghostty"4+5+// Golden screens for working across sheets: hiding a sheet and the list6+// of hidden ones, sheet names in formula suggestions, and where File >7+// Import puts a file.8+9+// hideSummary builds the two-sheet budget and hides Summary from its10+// tab's menu.11+func hideSummary(s *session) {12+ summary(s)13+ s.click(ghostty.MouseButtonRight, tabX(s.t, s, "Summary"), int(s.rows)-1)14+ s.waitFor("Hide sheet")15+ s.keys("<down>", "<down>", "<down>", "<enter>")16+ s.waitFor("Hid Summary")17+}18+19+// importLocation opens the import location picker for budget.tsv on a20+// spreadsheet with something in it.21+func importLocation(s *session) {22+ s.keys("Notes", "<enter>")23+ openImportPicker(s)24+ s.keys("tsv", "<enter>")25+ s.waitFor("Import location")26+}27+28+var sheetScreens = []screen{29+ {name: "sheets-menu-hide", setup: func(s *session) {30+ summary(s)31+ s.click(ghostty.MouseButtonRight, tabX(s.t, s, "Summary"), int(s.rows)-1)32+ s.waitFor("Hide sheet")33+ s.keys("<down>", "<down>", "<down>")34+ }},35+ {name: "sheets-hidden", setup: hideSummary},36+ {name: "sheets-hidden-picker", setup: func(s *session) {37+ hideSummary(s)38+ s.keys("<alt+v>", "<down>")39+ s.waitFor("List the hidden sheets")40+ s.keys("<enter>")41+ s.waitFor("1 of 1")42+ }},43+ {name: "autocomplete-sheet", setup: func(s *session) {44+ summary(s)45+ s.keys("<ctrl+pgup>", "<down>", "<down>", "<down>", "=B7/sum")46+ s.waitFor("│ Summary!")47+ }},48+ {name: "autocomplete-sheet-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {49+ summary(s)50+ s.keys("<ctrl+pgup>", "<down>", "<down>", "<down>", "=su")51+ s.waitFor("│ Summary!")52+ }},53+ {name: "autocomplete-sheet-point", setup: func(s *session) {54+ summary(s)55+ s.keys("<down>", "<down>", "<left>", "Rent share", "<tab>", "=sh", "<tab>", "<up>")56+ s.waitFor("POINT")57+ }},58+ {name: "import-location", files: importDir, setup: importLocation},59+ {name: "import-location-narrow", opts: options{cols: 60, rows: 16}, files: importDir, setup: importLocation},60+ {name: "import-new-sheets", files: importDir, setup: func(s *session) {61+ s.keys("Notes", "<enter>")62+ openImportPicker(s)63+ s.keys("q3", "<enter>")64+ s.waitFor("Import location")65+ s.keys("<enter>")66+ s.waitFor("Imported q3.xlsx as")67+ }},68+}69+70+func init() {71+ light := map[string]bool{"sheets-hidden-picker": true, "autocomplete-sheet": true, "import-location": true}72+ for _, sc := range sheetScreens {73+ screens = append(screens, sc)74+ if light[sc.name] {75+ sc.name += "-light"76+ sc.opts.light = true77+ screens = append(screens, sc)78+ }79+ }80+}
M e2e/sheets_test.go +55 -0
@@ -98,3 +98,58 @@ func TestSheets(t *testing.T) {
9898 s.waitForBar("A1", "Yearly total")9999 s.waitFor("24748.8")100100 }101+102+// Hiding a sheet takes it out of the tabs and the file keeps it hidden;103+// View > Hidden sheets shows it again, and undo hides it once more.104+func TestHideSheets(t *testing.T) {105+ dir := t.TempDir()106+ s := start(t, dir, "book.012")107+ hideSummary(s)108+ if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 +") {109+ t.Errorf("tabs %q", l)110+ }111+ s.keys("<ctrl+s>")112+ s.eventually("saved", func() bool {113+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))114+ return err == nil && strings.Contains(string(data), "\"name\": \"Summary\",\n \"hidden\": true,")115+ })116+ s.keys("<ctrl+q>")117+ s.waitExit()118+119+ s = start(t, dir, "book.012")120+ s.waitFor("Household budget 2026")121+ if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 +") {122+ t.Errorf("tabs after opening %q", l)123+ }124+ s.keys("<alt+v>", "<down>", "<enter>")125+ s.waitFor("Hidden sheets")126+ s.keys("<enter>")127+ s.waitFor("Unhid Summary")128+ s.waitFor("25900.8") // its formulas kept reading Sheet1129+ if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 Summary +") {130+ t.Errorf("tabs after unhiding %q", l)131+ }132+ s.keys("<ctrl+z>")133+ s.waitFor("Undid: show sheet Summary")134+ s.waitForBar("A1", "Household budget 2026")135+}136+137+// Typing a sheet's name in a formula offers it; after it, arrows point138+// into that sheet.139+func TestSheetNameSuggestions(t *testing.T) {140+ s := start(t, "")141+ summary(s)142+ s.keys("<ctrl+pgup>", "<f5>", "D1", "<enter>")143+ s.waitForName("D1")144+ s.keys("=su")145+ s.waitFor("│ Summary!")146+ s.keys("<tab>")147+ s.waitFor("=Summary!")148+ s.keys("<down>", "<up>")149+ s.waitFor("POINT")150+ s.waitFor("=Summary!B1")151+ s.keys("<enter>")152+ s.keys("<up>")153+ s.waitForBar("D1", "=Summary!B1")154+ s.waitFor("25900.8")155+}
A e2e/testdata/screens/autocomplete-sheet-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B8</span><span style="background:var(--vt-palette-7)"> </span> =B7/sum3+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="color:var(--vt-palette-0)">and</span> <span style="color:var(--vt-palette-0)">go</span> <span style="color:var(--vt-palette-0)">right</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Arrows</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">pick</span> <span style="color:var(--vt-palette-0)">cells</span> <span style="color:var(--vt-palette-0)">after</span> <span style="color:var(--vt-palette-0)">an</span> <span style="color:var(--vt-palette-0)">operator</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">┌────────────────────────────────────────────────────────────────┐</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Househo<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Summary!</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet,</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:B2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> SUM <span style="color:var(--vt-palette-0)">(value1,</span> <span style="color:var(--vt-palette-0)">[value2,</span> <span style="color:var(--vt-palette-0)">...])</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent <span style="color:var(--vt-palette-8)">│</span> SUMIF <span style="color:var(--vt-palette-0)">(range,</span> <span style="color:var(--vt-palette-0)">criterion,</span> <span style="color:var(--vt-palette-0)">[sum_range])</span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceri<span style="color:var(--vt-palette-8)">│</span> SUMIFS <span style="color:var(--vt-palette-0)">(sum_range,</span> <span style="color:var(--vt-palette-0)">criteria_range1,</span> <span style="color:var(--vt-palette-0)">criterion1,</span> <span style="color:var(--vt-palette-0)">[criteri…</span><span style="color:var(--vt-palette-8)">│</span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Transit<span style="color:var(--vt-palette-8)">│</span> SUMPRODUCT <span style="color:var(--vt-palette-0)">(array1,</span> <span style="color:var(--vt-palette-0)">[array2,</span> <span style="color:var(--vt-palette-0)">...])</span> <span style="color:var(--vt-palette-8)">│</span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────┘</span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> Total 2158.412+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">=B7/sum</span><span style="background:var(--vt-palette-6)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+Sheet Summary: A1:B2, 4 cells <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">insert</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">hide</span></pre>\ No newline at end of file
A e2e/testdata/screens/autocomplete-sheet-narrow.html +16 -0
@@ -0,0 +1,16 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B8</span><span style="background:var(--vt-palette-8)"> </span> =su3+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">go</span> <span style="color:var(--vt-palette-8)">right</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">pick</span> <span style="color:var(--vt-palette-8)">cel</span>4+<span style="color:var(--vt-palette-8)">┌──────────────────────────────────────────────────────────┐</span>5+<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Summary!</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet,</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:B2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">│</span> SUBSTITUTE <span style="color:var(--vt-palette-8)">(text,</span> <span style="color:var(--vt-palette-8)">search_for,</span> <span style="color:var(--vt-palette-8)">replace_with,</span> <span style="color:var(--vt-palette-8)">[occurrenc…│</span>7+<span style="color:var(--vt-palette-8)">│</span> SUM <span style="color:var(--vt-palette-8)">(value1,</span> <span style="color:var(--vt-palette-8)">[value2,</span> <span style="color:var(--vt-palette-8)">...])</span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> SUMIF <span style="color:var(--vt-palette-8)">(range,</span> <span style="color:var(--vt-palette-8)">criterion,</span> <span style="color:var(--vt-palette-8)">[sum_range])</span> <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">│</span> SUMIFS <span style="color:var(--vt-palette-8)">(sum_range,</span> <span style="color:var(--vt-palette-8)">criteria_range1,</span> <span style="color:var(--vt-palette-8)">criterion1,</span> <span style="color:var(--vt-palette-8)">[c…│</span>10+<span style="color:var(--vt-palette-8)">│</span> SUMPRODUCT <span style="color:var(--vt-palette-8)">(array1,</span> <span style="color:var(--vt-palette-8)">[array2,</span> <span style="color:var(--vt-palette-8)">...])</span> <span style="color:var(--vt-palette-8)">│</span>11+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────────────────┘</span>12+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">=su</span><span style="background:var(--vt-palette-6)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+Sheet Summary: A… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">insert</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">hide</span></pre>\ No newline at end of file
A e2e/testdata/screens/autocomplete-sheet-point.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">POINT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Summary!B3</span> =<span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">Sheet1!B4</span>3+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">pick</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">cell</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">pick</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">range</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">back</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 14508+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Groceries<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 9610+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.412+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> Summary <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/autocomplete-sheet.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B8</span><span style="background:var(--vt-palette-8)"> </span> =B7/sum3+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">go</span> <span style="color:var(--vt-palette-8)">right</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">pick</span> <span style="color:var(--vt-palette-8)">cells</span> <span style="color:var(--vt-palette-8)">after</span> <span style="color:var(--vt-palette-8)">an</span> <span style="color:var(--vt-palette-8)">operator</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">┌────────────────────────────────────────────────────────────────┐</span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Househo<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Summary!</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet,</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:B2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> SUM <span style="color:var(--vt-palette-8)">(value1,</span> <span style="color:var(--vt-palette-8)">[value2,</span> <span style="color:var(--vt-palette-8)">...])</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent <span style="color:var(--vt-palette-8)">│</span> SUMIF <span style="color:var(--vt-palette-8)">(range,</span> <span style="color:var(--vt-palette-8)">criterion,</span> <span style="color:var(--vt-palette-8)">[sum_range])</span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceri<span style="color:var(--vt-palette-8)">│</span> SUMIFS <span style="color:var(--vt-palette-8)">(sum_range,</span> <span style="color:var(--vt-palette-8)">criteria_range1,</span> <span style="color:var(--vt-palette-8)">criterion1,</span> <span style="color:var(--vt-palette-8)">[criteri…│</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit<span style="color:var(--vt-palette-8)">│</span> SUMPRODUCT <span style="color:var(--vt-palette-8)">(array1,</span> <span style="color:var(--vt-palette-8)">[array2,</span> <span style="color:var(--vt-palette-8)">...])</span> <span style="color:var(--vt-palette-8)">│</span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings<span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────┘</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.412+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">8</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">=B7/sum</span><span style="background:var(--vt-palette-6)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Sheet Summary: A1:B2, 4 cells <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">insert</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">hide</span></pre>\ No newline at end of file
A e2e/testdata/screens/import-location-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Import</span> <span style="font-weight:bold">budget.tsv</span> <span style="color:var(--vt-palette-8)">───────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-0)">Import</span> <span style="color:var(--vt-palette-0)">location</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">├───────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Notes <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Insert</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">new</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">after</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">the</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">others</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> Replace current sheet <span style="color:var(--vt-palette-0)">Sheet1</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Replace spreadsheet <span style="color:var(--vt-palette-0)">unsaved</span> <span style="color:var(--vt-palette-0)">changes</span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-8)">─┘</span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+Add budget.tsv as a new sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">import</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/import-location-narrow.html +16 -0
@@ -0,0 +1,16 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A2</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Import</span> <span style="font-weight:bold">budget.tsv</span> <span style="color:var(--vt-palette-8)">───────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Import</span> <span style="color:var(--vt-palette-8)">location</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">├───────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Insert</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">new</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">after</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">the</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">others</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="color:var(--vt-palette-8)">│</span> Replace current sheet <span style="color:var(--vt-palette-8)">Sheet1</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="color:var(--vt-palette-8)">│</span> Replace spreadsheet <span style="color:var(--vt-palette-8)">unsaved</span> <span style="color:var(--vt-palette-8)">changes</span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="color:var(--vt-palette-8)">└──────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">─┘</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+Add budget.ts… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">import</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/import-location.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Import</span> <span style="font-weight:bold">budget.tsv</span> <span style="color:var(--vt-palette-8)">───────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Import</span> <span style="color:var(--vt-palette-8)">location</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">├───────────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Notes <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Insert</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">new</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">after</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">the</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">others</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> Replace current sheet <span style="color:var(--vt-palette-8)">Sheet1</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Replace spreadsheet <span style="color:var(--vt-palette-8)">unsaved</span> <span style="color:var(--vt-palette-8)">changes</span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">─┘</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Add budget.tsv as a new sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">import</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/import-new-sheets.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Region3+Imported q3.xlsx as Q3 and Q4 (6 rows)4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Region</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">July</span> <span style="font-weight:bold">August</span> <span style="font-weight:bold">September</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> North $1,200.50 $1,310.00 $990.007+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> South $870.00 $905.25 $1,122.008+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> West $450.00 $610.00 $702.759+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Total</span> $2,520.50 $2,825.25 $2,814.7510+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+ Sheet1 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Q3</span><span style="background:var(--vt-palette-6)"> </span> Q4 <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
M e2e/testdata/screens/menu-freeze.html +5 -5
@@ -2,11 +2,11 @@
22 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">┌───────────────────────────────┐┌─────────────────────────┐</span>33 <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Freeze</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">││</span> No rows <span style="color:var(--vt-palette-8)">│</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">├───────────────────────────────┤│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">row</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Item</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> Command palette <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> 2 rows <span style="color:var(--vt-palette-8)">│</span>6-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Bolts<span style="color:var(--vt-palette-8)">│</span> Keyboard shortcuts <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> Up to current row <span style="color:var(--vt-palette-8)">│</span>7-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Nuts <span style="color:var(--vt-palette-8)">└───────────────────────────────┘├─────────────────────────┤</span>8-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Washers 1 Fasteners B03 79<span style="color:var(--vt-palette-8)">│</span> No columns <span style="color:var(--vt-palette-8)">│</span>9-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Hinges 1 Hardware B04 26<span style="color:var(--vt-palette-8)">│</span> 1 column <span style="color:var(--vt-palette-8)">│</span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Item</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Hidden</span> <span style="color:var(--vt-palette-8)">sheets</span> <span style="color:var(--vt-palette-8)">││</span> 2 rows <span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Bolts<span style="color:var(--vt-palette-8)">├───────────────────────────────┤│</span> Up to current row <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Nuts <span style="color:var(--vt-palette-8)">│</span> Command palette <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│├─────────────────────────┤</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Washe<span style="color:var(--vt-palette-8)">│</span> Keyboard shortcuts <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> No columns <span style="color:var(--vt-palette-8)">│</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Hinge<span style="color:var(--vt-palette-8)">└───────────────────────────────┘│</span> 1 column <span style="color:var(--vt-palette-8)">│</span>1010 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Brackets Hardware B05 63<span style="color:var(--vt-palette-8)">│</span> 2 columns <span style="color:var(--vt-palette-8)">│</span>1111 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Screws 1 Fasteners B06 10<span style="color:var(--vt-palette-8)">│</span> Up to current column <span style="color:var(--vt-palette-8)">│</span>1212 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> Anchors 1 Hardware B07 47<span style="color:var(--vt-palette-8)">└─────────────────────────┘</span>
M e2e/testdata/screens/palette-narrow.html +1 -1
@@ -12,5 +12,5 @@
1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">30</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">30</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">126</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/palette-search-light.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Insert</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">20</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">20</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">126</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/palette-search.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Insert</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">20</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">20</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">126</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-wide.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">126</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Vim keys <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Theme <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">126</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">126</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
A e2e/testdata/screens/sheets-hidden-picker-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B5</span><span style="background:var(--vt-palette-7)"> </span> 96 <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Hidden</span> <span style="font-weight:bold">sheets</span> <span style="color:var(--vt-palette-8)">────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-0)">Type</span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">sheet</span> <span style="color:var(--vt-palette-0)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────┤</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget 2026 <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Summary</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:B2,</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">4</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">cells</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-8)">─┘</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 14508+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> Transit <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">96</span><span style="background:var(--vt-palette-6)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> Total 2158.412+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+Show Summary again <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">unhide</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/sheets-hidden-picker.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B5</span><span style="background:var(--vt-palette-8)"> </span> 96 <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Hidden</span> <span style="font-weight:bold">sheets</span> <span style="color:var(--vt-palette-8)">────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">sheet</span> <span style="color:var(--vt-palette-8)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 2026 <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Summary</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:B2,</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">4</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">cells</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">─┘</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 14508+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> Transit <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">96</span><span style="background:var(--vt-palette-6)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.412+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Show Summary again <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">unhide</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/sheets-hidden.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B5</span><span style="background:var(--vt-palette-8)"> </span> 963+Hid Summary; View &gt; Hidden sheets shows it again4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 14508+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> Transit <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">96</span><span style="background:var(--vt-palette-6)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.412+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/sheets-menu-hide.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B1</span><span style="background:var(--vt-palette-8)"> </span> =Sheet1!B7*123+4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span> Yearly to<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">25900.8</span><span style="background:var(--vt-palette-6)"> </span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Monthly 719.466677+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">┌───────────────────────────────┐</span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Rename <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Duplicate <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-8)">│</span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Hide</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">sheet</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────┤</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move left <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">right</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────┤</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Hidden</span> <span style="color:var(--vt-palette-8)">sheets</span> <span style="color:var(--vt-palette-8)">│</span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────┘</span>30+Hide the sheet&#39;s tab; formulas still read it <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/sheets-menu-light.html +13 -13
@@ -14,17 +14,17 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">┌─────────────────────────────┐</span>20-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Rename</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>21-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Duplicate <span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────┤</span>24-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move left <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">right</span> <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────┤</span>27-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+F11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>29-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">┌───────────────────────────────┐</span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Rename</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Duplicate <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-8)">│</span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Hide sheet <span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────┤</span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move left <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">right</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────┤</span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+F11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Hidden</span> <span style="color:var(--vt-palette-8)">sheets</span> <span style="color:var(--vt-palette-8)">│</span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────┘</span>3030 Give the sheet a new name; formulas that use it follow <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">choose</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/sheets-menu.html +13 -13
@@ -14,17 +14,17 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">┌─────────────────────────────┐</span>20-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Rename</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>21-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Duplicate <span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────┤</span>24-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move left <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">right</span> <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────┤</span>27-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>29-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">┌───────────────────────────────┐</span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Rename</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Duplicate <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete <span style="color:var(--vt-palette-8)">│</span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Hide sheet <span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────┤</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move left <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Move</span> <span style="color:var(--vt-palette-8)">right</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────┤</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Hidden</span> <span style="color:var(--vt-palette-8)">sheets</span> <span style="color:var(--vt-palette-8)">│</span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────┘</span>3030 Give the sheet a new name; formulas that use it follow <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M internal/fileio/fileio.go +1 -1
M internal/fileio/snapshot.go +4 -0
M internal/fileio/stress_test.go +84 -14
@@ -12,8 +12,10 @@ import (
1212 "os"1313 "path/filepath"1414 "runtime"15+ "runtime/metrics"1516 "strconv"1617 "testing"18+ "time"1719 1820 "github.com/FelineStateMachine/012/internal/sheet"1921 "github.com/FelineStateMachine/012/internal/stress"
@@ -61,25 +63,93 @@ func BenchmarkImport(b *testing.B) {
6163 if k, _ := KindOf(name); k == SQLite {6264 opt.Table = "PlaylistTrack" // the biggest table6365 }64- var res *Result65- for b.Loop() {66- if res, err = Import(context.Background(), path, opt); err != nil {67- b.Fatal(err)68- }66+ benchImport(b, path, st.Size(), opt)67+ })68+ }69+}70+71+// BenchmarkImportXLSX imports synthetic workbooks written by 012's72+// exporter: numbers, text and a column of formulas, each a full 819273+// rows.74+func BenchmarkImportXLSX(b *testing.B) {75+ for _, tc := range []struct {76+ name string77+ s *sheet.Sheet78+ }{79+ {"numbers-8192x26", stress.Dense(stress.Rows, 26)},80+ {"table-8192x26", stress.Table(stress.Rows-1, 26)},81+ {"formulas-8192", stress.Chain(stress.Rows)},82+ } {83+ b.Run(tc.name, func(b *testing.B) {84+ path := filepath.Join(b.TempDir(), tc.name+".xlsx")85+ if _, err := Export(context.Background(), path, XLSX, Snap(tc.s, sheet.Rect{}, "data"), ExportOptions{}); err != nil {86+ b.Fatal(err)6987 }70- b.SetBytes(st.Size())71- cells := res.Sheet.Len()72- b.ReportMetric(float64(cells), "cells")73- b.ReportMetric(float64(cells)*float64(b.N)/b.Elapsed().Seconds(), "cells/s")74- res = nil75- before := liveHeap()76- res, _ = Import(context.Background(), path, opt)77- b.ReportMetric(float64(liveHeap()-before)/float64(max(cells, 1)), "B/cell")78- runtime.KeepAlive(res)88+ st, err := os.Stat(path)89+ if err != nil {90+ b.Fatal(err)91+ }92+ benchImport(b, path, st.Size(), Options{})7993 })8094 }8195 }8296 97+// benchImport imports path in a loop and reports throughput, cells kept,98+// the live heap of the imported sheet per cell (B/cell) and the peak99+// heap while importing, per cell (peak-B/cell), sampled.100+func benchImport(b *testing.B, path string, size int64, opt Options) {101+ var res *Result102+ var err error103+ for b.Loop() {104+ if res, err = Import(context.Background(), path, opt); err != nil {105+ b.Fatal(err)106+ }107+ }108+ b.SetBytes(size)109+ cells := res.Sheet.Len()110+ b.ReportMetric(float64(cells), "cells")111+ b.ReportMetric(float64(cells)*float64(b.N)/b.Elapsed().Seconds(), "cells/s")112+ res = nil113+ before := liveHeap()114+ stop := samplePeak()115+ res, _ = Import(context.Background(), path, opt)116+ peak := stop()117+ b.ReportMetric(float64(liveHeap()-before)/float64(max(cells, 1)), "B/cell")118+ b.ReportMetric(float64(peak-min(peak, before))/float64(max(cells, 1)), "peak-B/cell")119+ runtime.KeepAlive(res)120+}121+122+// samplePeak samples the heap every 100 microseconds until the returned123+// function is called, which returns the most seen: live objects and124+// garbage not yet collected, what the process holds.125+func samplePeak() func() uint64 {126+ sample := []metrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}127+ read := func() uint64 {128+ metrics.Read(sample)129+ return sample[0].Value.Uint64()130+ }131+ done := make(chan struct{})132+ peak := make(chan uint64)133+ go func() {134+ hi := read()135+ t := time.NewTicker(100 * time.Microsecond)136+ defer t.Stop()137+ for {138+ select {139+ case <-done:140+ peak <- max(hi, read())141+ return142+ case <-t.C:143+ hi = max(hi, read())144+ }145+ }146+ }()147+ return func() uint64 {148+ close(done)149+ return <-peak150+ }151+}152+83153 // BenchmarkExport writes a dense numeric sheet in each export format and84154 // reports the file size.85155 func BenchmarkExport(b *testing.B) {
M internal/fileio/wk1.go +1 -1
M internal/fileio/xlsx.go +121 -326
@@ -1,17 +1,22 @@
11 package fileio22 33 import (4+ "archive/zip"5+ "bufio"46 "context"57 "fmt"68 "io"79 "strconv"810 "strings"911 10- "github.com/xuri/excelize/v2"11-12+ "github.com/FelineStateMachine/012/internal/formula"1213 "github.com/FelineStateMachine/012/internal/sheet"1314 )1415 16+// XLSX files are read by xlsximport.go (on xlsxpkg.go, xlsxbook.go,17+// xlsxstyles.go and xlsxsheet.go) and written here and in xlsxwrite.go,18+// all with archive/zip and encoding/xml's escaping.19+1520 // Column widths: Excel measures in characters of its default font, 0121621 // in terminal columns including one of padding.1722 const (
@@ -19,368 +24,158 @@ const (
1924 excelPadding = 12025 )2126 22-// importXLSX reads every sheet of a workbook, with its named ranges, and23-// returns the sheet that was active in Excel.24-func importXLSX(ctx context.Context, name string, opt Options) (*Result, error) {25- prog := opt.Progress26- x, err := excelize.OpenFile(name)27- if err != nil {28- return nil, err29- }30- defer x.Close()31- names := x.GetSheetList()32- if len(names) == 0 {33- return nil, fmt.Errorf("the workbook has no sheets")34- }35- // Rows across all sheets, for the progress bar.36- dims := make([]int, len(names))37- total := 038- for i, ws := range names {39- if dim, err := x.GetSheetDimension(ws); err == nil {40- if _, to, ok := strings.Cut(dim, ":"); ok {41- if _, r, err := excelize.CellNameToCoordinates(to); err == nil {42- dims[i] = r43- }44- }45- }46- total += dims[i]47- }48-49- b := newBuilder(opt.MaxCells)50- book := b.s.Book()51- styles := map[int]xlsxStyle{}52- var notes []string53- done := 054- for i, ws := range names {55- next := b.s56- if i == 0 {57- err = book.RenameSheet(b.s, ws)58- } else {59- next, err = book.AddSheet(ws, i)60- }61- if err != nil {62- return nil, fmt.Errorf("sheet %s: %w", ws, err)63- }64- b.nextSheet(next)65- rows, err := importXLSXSheet(ctx, x, b, ws, styles, func(row int) {66- prog.setRows(done + row)67- prog.setFrac(int64(done+row), int64(total))68- })69- if err != nil {70- return nil, err71- }72- done += rows73- }74- notes = append(notes, importXLSXNames(x, book)...)75- active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))76- book.SetActive(active)77- b.s = active78- prog.setRows(done)79- s, notes := b.finish(notes)80- return &Result{Sheet: s, Rows: done, Notes: notes}, nil81-}82-8327 func clamp(v, lo, hi int) int { return max(lo, min(v, hi)) }8428 85-// importXLSXSheet reads one sheet into b.s, reporting rows read, and86-// returns how many rows it read.87-func importXLSXSheet(ctx context.Context, x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle, report func(int)) (int, error) {88- rows, err := x.Rows(ws)89- if err != nil {90- return 0, err91- }92- defer rows.Close()93- row, width := 0, 094- for ; rows.Next(); row++ {95- if row%128 == 0 {96- if err := ctx.Err(); err != nil {97- return 0, err98- }99- report(row)100- }101- cols, err := rows.Columns(excelize.Options{RawCellValue: true})102- if err != nil {103- return 0, err104- }105- importXLSXRowStyle(x, b, rows, row, styles)106- width = max(width, len(cols))107- for col, raw := range cols {108- a := sheet.Addr{Col: col, Row: row}109- if !b.fits(a) {110- break111- }112- cell, _ := excelize.CoordinatesToCellName(col+1, row+1)113- importXLSXCell(x, b, ws, cell, a, raw, styles)114- }115- }116- if err := rows.Error(); err != nil {117- return 0, err118- }119- importXLSXColumns(x, b, ws, styles, width)120- return row, nil121-}122-123-// importXLSXNames defines the workbook's named ranges that are a range on124-// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet125-// scoped names, constants, formulas).126-func importXLSXNames(x *excelize.File, book *sheet.Workbook) []string {127- skipped := 0128- example := ""129- for _, dn := range x.GetDefinedName() {130- if strings.HasPrefix(dn.Name, "_xlnm.") {131- continue // print areas and the like132- }133- ref := strings.TrimPrefix(dn.RefersTo, "=")134- ws, rest := sheet.SplitSheet(ref)135- r, ok := sheet.ParseRange(rest)136- s := book.Lookup(ws)137- if dn.Scope != "" && dn.Scope != "Workbook" || !ok || s == nil || book.DefineName(dn.Name, s, r) != nil {138- skipped++139- if example == "" {140- example = dn.Name141- }142- }143- }144- if skipped > 0 {145- return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}146- }147- return nil148-}149-15029 // xlsxStyle is what 012 keeps of an Excel cell style.15130 type xlsxStyle struct {15231 format sheet.Format15332 style sheet.Style15433 }15534 156-func styleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyle {157- if st, ok := cache[id]; ok {158- return st159- }160- var out xlsxStyle161- if st, err := x.GetStyle(id); err == nil && st != nil {162- code := ""163- if st.CustomNumFmt != nil {164- code = *st.CustomNumFmt165- }166- out.format = formatOf(st.NumFmt, code)167- if f := st.Font; f != nil {168- out.style.Bold, out.style.Italic, out.style.Strikethrough = f.Bold, f.Italic, f.Strike169- out.style.Underline = f.Underline != "" && f.Underline != "none"170- }171- if al := st.Alignment; al != nil {172- switch al.Horizontal {173- case "left":174- out.style.Align = sheet.AlignLeft175- case "center", "centerContinuous":176- out.style.Align = sheet.AlignCenter177- case "right":178- out.style.Align = sheet.AlignRight179- }180- }181- }182- cache[id] = out183- return out184-}185-186-func importXLSXCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr, raw string, styles map[int]xlsxStyle) {187- var st xlsxStyle188- if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 {189- st = styleOf(x, id, styles)190- }191- typ, _ := x.GetCellType(ws, cell)192- keep := func() {193- switch typ {194- case excelize.CellTypeBool:195- b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)196- return197- case excelize.CellTypeSharedString, excelize.CellTypeInlineString, excelize.CellTypeFormula, excelize.CellTypeError:198- b.text(a, raw, st.format, st.style)199- return200- }201- if v, err := strconv.ParseFloat(raw, 64); err == nil {202- b.number(a, fromExcelSerial(v, st.format), st.format, st.style)203- return204- }205- b.text(a, raw, st.format, st.style)206- }207- if f, err := x.GetCellFormula(ws, cell); err == nil && f != "" {208- b.formula(a, fromExcelFormula(f), st.format, st.style, keep)209- return210- }211- if raw == "" {212- b.put(a, "", st.format, st.style)213- return214- }215- keep()216-}217-21835 // exportXLSX writes a workbook: the snapshot's sheets (or just the21936 // snapshot), each with values, formulas in Excel's syntax with their22037 // results cached, number formats, text styles, alignment and column221-// widths, and the named ranges.38+// widths, and the named ranges. Excel recalculates it on opening.22239 func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions) (*ExportResult, error) {223- x := excelize.NewFile()224- defer x.Close()22540 sheets := snap.Sheets22641 if len(sheets) == 0 {22742 sheets = []*Snapshot{snap}22843 }229- w := &xlsxWriter{x: x, styleIDs: map[xlsxStyle]int{}, multi: len(sheets) > 1}44+ w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}}23045 res := &ExportResult{}46+ names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 047+ used := map[string]bool{}23148 for i, sn := range sheets {232- ws := sheetName(sn.Name)233- var err error234- if i == 0 {235- err = x.SetSheetName("Sheet1", ws)236- } else {237- _, err = x.NewSheet(ws)238- }239- if err != nil {240- return nil, err241- }242- rows, err := w.sheet(ws, sn)243- if err != nil {244- return nil, err49+ names[i] = uniqueSheetName(sheetName(sn.Name), used)50+ if sn == snap {51+ active = i52+ }53+ hidden[i] = sn.Hidden54+ // A formula can name only a sheet written under its own name:55+ // one renamed to suit Excel goes out as values, as a missing one.56+ if names[i] == sn.Name {57+ w.known[formula.SheetKey(sn.Name)] = true58+ }59+ }60+ hidden[active] = false61+ err := writeFile(name, func(out io.Writer) error {62+ zw := zip.NewWriter(out)63+ for i, sn := range sheets {64+ rows, err := w.writeSheet(zw, i, names[i], sn, i == active)65+ if err != nil {66+ return err67+ }68+ res.Rows += rows24569 }246- res.Rows += rows247- if sn == snap || snap.Sheets == nil {248- x.SetActiveSheet(i)70+ if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {71+ return err24972 }250- }251- for _, n := range snap.Names {252- if err := x.SetDefinedName(&excelize.DefinedName{Name: n[0], RefersTo: n[1]}); err != nil {253- return nil, err73+ if err := writePackage(zw, names, hidden, active, snap.Names); err != nil {74+ return err25475 }255- }256- yes := true257- if err := x.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: &yes}); err != nil {76+ return zw.Close()77+ })78+ if err != nil {25879 return nil, err25980 }260- if w.values > 0 {81+ if w.values.n > 0 {26182 res.Notes = append(res.Notes, fmt.Sprintf("%s with no Excel equivalent saved as values, e.g. %s",262- count(w.values, "formula", "formulas"), w.example))83+ count(w.values.n, "formula", "formulas"), w.values.example))26384 }264- return res, writeFile(name, func(w io.Writer) error { return x.Write(w) })265-}266-267-// xlsxWriter writes sheets into a workbook, sharing cell styles.268-type xlsxWriter struct {269- x *excelize.File270- styleIDs map[xlsxStyle]int271- values int // formulas written as values272- example string // the first of them273- multi bool // examples name their sheet85+ if w.missing.n > 0 {86+ res.Notes = append(res.Notes, fmt.Sprintf("%s naming a sheet that doesn't exist saved as values, e.g. %s (%s)",87+ count(w.missing.n, "formula", "formulas"), w.missing.example, sheet.QuoteSheet(w.missingSheet)))88+ }89+ return res, nil27490 }27591 276-func (w *xlsxWriter) styleFor(f sheet.Format, st sheet.Style) (int, error) {277- key := xlsxStyle{f, st}278- if id, ok := w.styleIDs[key]; ok {279- return id, nil280- }281- xs := &excelize.Style{}282- if code := excelCode(f); code != "" {283- xs.CustomNumFmt = &code284- }285- if st.Bold || st.Italic || st.Underline || st.Strikethrough {286- xs.Font = &excelize.Font{Bold: st.Bold, Italic: st.Italic, Strike: st.Strikethrough}287- if st.Underline {288- xs.Font.Underline = "single"289- }92+// uniqueSheetName is name, or name with a number when another sheet93+// already took it (Excel's names ignore case).94+func uniqueSheetName(name string, used map[string]bool) string {95+ out := name96+ for n := 2; used[strings.ToLower(out)]; n++ {97+ suffix := " (" + strconv.Itoa(n) + ")"98+ r := []rune(name)99+ out = string(r[:min(len(r), 31-len(suffix))]) + suffix290100 }291- if st.Align != sheet.AlignAuto {292- xs.Alignment = &excelize.Alignment{Horizontal: st.Align.String()}293- }294- id, err := w.x.NewStyle(xs)295- w.styleIDs[key] = id296- return id, err101+ used[strings.ToLower(out)] = true102+ return out297103 }298104 299-// sheet writes a snapshot to sheet ws and returns the rows written.300-func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {301- sw, err := w.x.NewStreamWriter(ws)302- if err != nil {303- return 0, err304- }305- for c, width := range snap.Widths {306- if err := sw.SetColWidth(c+1, c+1, float64(max(width-excelPadding, 1))); err != nil {307- return 0, err308- }309- }310- if err := w.colStyles(sw, snap.ColFormats); err != nil {311- return 0, err312- }313- r := snap.Range314- line := make([]any, r.To.Col-r.From.Col+1)315- for row := r.From.Row; row <= r.To.Row; row++ {316- for col := r.From.Col; col <= r.To.Col; col++ {317- a := sheet.Addr{Col: col, Row: row}318- line[col-r.From.Col] = nil319- if c, ok := snap.Cells[a]; ok {320- if line[col-r.From.Col], err = w.cell(ws, a, c); err != nil {321- return 0, err322- }323- }324- }325- cell, _ := excelize.CoordinatesToCellName(r.From.Col+1, row+1)326- opts, err := w.rowOpts(snap.RowFormats, row)327- if err != nil {328- return 0, err329- }330- if err := sw.SetRow(cell, line, opts...); err != nil {331- return 0, err105+const (106+ xmlHead = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"107+ relsNS = `http://schemas.openxmlformats.org/package/2006/relationships`108+ officeRel = `http://schemas.openxmlformats.org/officeDocument/2006/relationships`109+ sheetMain = `http://schemas.openxmlformats.org/spreadsheetml/2006/main`110+ mlType = `application/vnd.openxmlformats-officedocument.spreadsheetml.`111+)112+113+// writePackage writes the parts around the worksheets and styles: the114+// workbook with its sheets, active tab, names and calculation settings,115+// the relationships and the content types.116+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string) error {117+ var types, rels, book strings.Builder118+ types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +119+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +120+ `<Default Extension="xml" ContentType="application/xml"/>` +121+ `<Override PartName="/xl/workbook.xml" ContentType="` + mlType + `sheet.main+xml"/>` +122+ `<Override PartName="/xl/styles.xml" ContentType="` + mlType + `styles+xml"/>`)123+ rels.WriteString(xmlHead + `<Relationships xmlns="` + relsNS + `">`)124+ book.WriteString(xmlHead + `<workbook xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">` +125+ `<bookViews><workbookView activeTab="` + strconv.Itoa(active) + `"/></bookViews><sheets>`)126+ for i, ws := range names {127+ n := strconv.Itoa(i + 1)128+ fmt.Fprintf(&types, `<Override PartName="/xl/worksheets/sheet%s.xml" ContentType="%sworksheet+xml"/>`, n, mlType)129+ fmt.Fprintf(&rels, `<Relationship Id="rId%s" Type="%s/worksheet" Target="worksheets/sheet%s.xml"/>`, n, officeRel, n)130+ state := ""131+ if hidden[i] {132+ state = ` state="hidden"`133+ }134+ fmt.Fprintf(&book, `<sheet name="%s" sheetId="%s"%s r:id="rId%s"/>`, escapeXML(ws, true), n, state, n)135+ }136+ fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/></Relationships>`, len(names)+1, officeRel)137+ types.WriteString(`</Types>`)138+ book.WriteString(`</sheets>`)139+ if len(defined) > 0 {140+ book.WriteString(`<definedNames>`)141+ for _, n := range defined {142+ fmt.Fprintf(&book, `<definedName name="%s">%s</definedName>`, escapeXML(n[0], true), escapeXML(n[1], false))143+ }144+ book.WriteString(`</definedNames>`)145+ }146+ book.WriteString(`<calcPr calcId="191029" fullCalcOnLoad="1"/></workbook>`)147+ for _, p := range []struct{ name, body string }{148+ {"[Content_Types].xml", types.String()},149+ {"_rels/.rels", xmlHead + `<Relationships xmlns="` + relsNS + `"><Relationship Id="rId1" Type="` + officeRel + `/officeDocument" Target="xl/workbook.xml"/></Relationships>`},150+ {"xl/workbook.xml", book.String()},151+ {"xl/_rels/workbook.xml.rels", rels.String()},152+ } {153+ if err := writePart(zw, p.name, p.body); err != nil {154+ return err332155 }333156 }334- if err := w.styledRowsAfter(sw, snap.RowFormats, r.To.Row); err != nil {335- return 0, err336- }337- if err := sw.Flush(); err != nil {338- return 0, err339- }340- return r.To.Row - r.From.Row + 1, nil157+ return nil341158 }342159 343-// cell is the snapshot cell c at a as excelize writes it. A formula with344-// no Excel equivalent is written as its value, and counted.345-func (w *xlsxWriter) cell(ws string, a sheet.Addr, c SnapCell) (excelize.Cell, error) {346- // A formula's inferred format is written too, so its result shows347- // the same in Excel.348- id := 0349- if !c.Format.IsZero() || !c.Style.IsZero() {350- var err error351- if id, err = w.styleFor(c.Format, c.Style); err != nil {352- return excelize.Cell{}, err353- }354- }355- xc := excelize.Cell{StyleID: id, Value: xlsxValue(c.Value, c.Format)}356- if !c.Formula {357- return xc, nil358- }359- if fx, ok := toExcelFormula(c.Input); ok {360- xc.Formula = fx361- return xc, nil362- }363- w.values++364- if w.example == "" {365- w.example = a.String()366- if w.multi {367- w.example = sheet.QuoteSheet(ws) + "!" + a.String()368- }160+func writePart(zw *zip.Writer, name, body string) error {161+ w, err := zw.Create(name)162+ if err != nil {163+ return err369164 }370- return xc, nil165+ _, err = io.WriteString(w, body)166+ return err371167 }372168 373-// xlsxValue is a computed value as excelize writes it.374-func xlsxValue(v sheet.Value, f sheet.Format) any {375- switch v.Kind {376- case sheet.Number:377- return toExcelSerial(v.Num, f)378- case sheet.Bool:379- return v.Num != 0380- case sheet.Text, sheet.Error:381- return v.Str169+// writeSheet writes snapshot sn as worksheet part i and returns the rows170+// written.171+func (w *xlsxWriter) writeSheet(zw *zip.Writer, i int, ws string, sn *Snapshot, active bool) (int, error) {172+ part, err := zw.Create("xl/worksheets/sheet" + strconv.Itoa(i+1) + ".xml")173+ if err != nil {174+ return 0, err382175 }383- return nil176+ bw := bufio.NewWriterSize(part, 64<<10)177+ rows := w.sheet(bw, ws, sn, active)178+ return rows, bw.Flush()384179 }385180 386181 // sheetName makes a valid Excel sheet name: at most 31 characters, none
A internal/fileio/xlsx_diff_test.go +205 -0
@@ -0,0 +1,205 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/xuri/excelize/v2"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/stress"15+)16+17+// TestXLSXDifferential imports a corpus of workbooks with excelize (the18+// reader 012 used before its own) and with 012's streaming reader, and19+// checks the results are the same: every sheet's cells as saved (input,20+// format and style), column widths, named ranges, the sheet shown, the21+// rows counted and the notes. The corpus is workbooks written here, the22+// stress datasets' XLSX files when fetched (scripts/stress-data.sh), and23+// every .xlsx file in the directories listed in XLSX_CORPUS24+// (colon-separated), such as excelize's own test files.25+//26+// Known differences, where excelize's reading is wrong and 012's isn't,27+// are left out of the comparison (see compareXLSX).28+func TestXLSXDifferential(t *testing.T) {29+ for _, path := range xlsxCorpus(t) {30+ t.Run(filepath.Base(path), func(t *testing.T) {31+ want, errWant := importXLSXExcelize(context.Background(), path, Options{})32+ got, errGot := importXLSX(context.Background(), path, Options{})33+ if (errWant == nil) != (errGot == nil) {34+ t.Fatalf("excelize: %v; 012: %v", errWant, errGot)35+ }36+ if errWant != nil {37+ t.Logf("both fail: excelize: %v; 012: %v", errWant, errGot)38+ return39+ }40+ compareXLSX(t, path, want, got)41+ })42+ }43+}44+45+func xlsxCorpus(t *testing.T) []string {46+ dir := t.TempDir()47+ files := []string{writeParts(t, dir, "kitchen.xlsx", kitchenParts())}48+ files = append(files, writeExcelizeBook(t, dir), writeExportedBook(t, dir))49+ stressData := os.Getenv("STRESS_DIR")50+ if stressData == "" {51+ stressData = filepath.Join("..", "..", ".deps", "stress")52+ }53+ files = append(files, globXLSX(stressData)...)54+ for _, d := range filepath.SplitList(os.Getenv("XLSX_CORPUS")) {55+ files = append(files, globXLSX(d)...)56+ }57+ return files58+}59+60+func globXLSX(dir string) []string {61+ if dir == "" {62+ return nil63+ }64+ m, _ := filepath.Glob(filepath.Join(dir, "*.xlsx"))65+ return m66+}67+68+// writeExcelizeBook writes a workbook with excelize: sheets referring to69+// each other, styles, widths and names.70+func writeExcelizeBook(t *testing.T, dir string) string {71+ x := excelize.NewFile()72+ defer x.Close()73+ x.SetSheetName("Sheet1", "Q1")74+ x.NewSheet("Q2 plan")75+ x.SetSheetRow("Q1", "A1", &[]any{"Region", 1234.5, "=text", 45000, true, "12%", "TRUE", -0.5})76+ x.SetCellFormula("Q1", "A2", "'Q2 plan'!A1*2")77+ x.SetCellFormula("Q1", "B2", "SUM(B1,1)")78+ x.SetCellFormula("Q1", "C2", "CUBEVALUE(1)")79+ x.SetCellValue("Q1", "E4", "after a gap")80+ x.SetCellValue("Q2 plan", "A1", 42)81+ x.SetCellValue("Q2 plan", "B300", "far down")82+ bold, _ := x.NewStyle(&excelize.Style{NumFmt: 4, Font: &excelize.Font{Bold: true, Underline: "double"}})83+ date, _ := x.NewStyle(&excelize.Style{NumFmt: 15, Alignment: &excelize.Alignment{Horizontal: "right"}})84+ code := `[$$-409]#,##0.00`85+ money, _ := x.NewStyle(&excelize.Style{CustomNumFmt: &code, Font: &excelize.Font{Italic: true, Strike: true}})86+ x.SetCellStyle("Q1", "B1", "B1", bold)87+ x.SetCellStyle("Q1", "D1", "D1", date)88+ x.SetCellStyle("Q1", "A2", "C3", money)89+ x.SetColWidth("Q1", "A", "B", 14)90+ x.SetColWidth("Q2 plan", "C", "C", 30)91+ x.SetDefinedName(&excelize.DefinedName{Name: "Rent", RefersTo: "Q1!$B$1"})92+ x.SetDefinedName(&excelize.DefinedName{Name: "Mine", RefersTo: "Q1!$B$1", Scope: "Q2 plan"})93+ x.SetActiveSheet(1)94+ path := filepath.Join(dir, "excelize.xlsx")95+ if err := x.SaveAs(path); err != nil {96+ t.Fatal(err)97+ }98+ return path99+}100+101+// writeExportedBook writes a table and a sheet of formulas with 012's102+// exporter.103+func writeExportedBook(t *testing.T, dir string) string {104+ s := stress.Table(60, 10)105+ book := s.Book()106+ f, _ := book.AddSheet("Formulas", 1)107+ for row := range 40 {108+ f.Set(sheet.Addr{Col: 0, Row: row}, "=Sheet1!C"+itoa(row+2)+"*2")109+ f.Set(sheet.Addr{Col: 1, Row: row}, "=SUM($A$1:A"+itoa(row+1)+")")110+ }111+ f.SetColWidth(0, 20)112+ path := filepath.Join(dir, "exported.xlsx")113+ if _, err := Export(context.Background(), path, XLSX, SnapBook(f), ExportOptions{}); err != nil {114+ t.Fatal(err)115+ }116+ return path117+}118+119+// compareXLSX compares two imports of path.120+func compareXLSX(t *testing.T, path string, want, got *Result) {121+ t.Helper()122+ if want.Rows != got.Rows {123+ t.Errorf("rows %d, excelize %d", got.Rows, want.Rows)124+ }125+ if w, g := strings.Join(want.Notes, "; "), strings.Join(got.Notes, "; "); w != g {126+ t.Errorf("notes %q, excelize %q", g, w)127+ }128+ if want.Sheet.Name() != got.Sheet.Name() {129+ t.Errorf("showing %s, excelize %s", got.Sheet.Name(), want.Sheet.Name())130+ }131+ skip := mergedCells(t, path)132+ w, g := savedLines(t, want.Sheet, skip), savedLines(t, got.Sheet, skip)133+ if w == g {134+ return135+ }136+ wl, gl := strings.Split(w, "\n"), strings.Split(g, "\n")137+ shown := 0138+ for i := 0; i < max(len(wl), len(gl)) && shown < 20; i++ {139+ var a, b string140+ if i < len(wl) {141+ a = wl[i]142+ }143+ if i < len(gl) {144+ b = gl[i]145+ }146+ if a != b && !sameSharedFormula(a, b) {147+ t.Errorf("line %d:\n 012: %s\n excelize: %s", i+1, b, a)148+ shown++149+ }150+ }151+}152+153+// sameSharedFormula reports whether two saved lines are the same formula154+// but for spaces. excelize expands a shared formula by parsing it and155+// writing it back, which drops the spaces between its tokens; 012 moves156+// the references in the text as written.157+func sameSharedFormula(a, b string) bool {158+ return strings.Contains(a, `": "=`) && strings.ReplaceAll(a, " ", "") == strings.ReplaceAll(b, " ", "")159+}160+161+// savedLines is the workbook as 012 saves it, without the cells in skip162+// (by sheet), which excelize reads wrongly.163+func savedLines(t *testing.T, s *sheet.Sheet, skip map[string][]sheet.Rect) string {164+ t.Helper()165+ book := s.Book()166+ for i := range book.Len() {167+ ws := book.Sheet(i)168+ for _, r := range skip[ws.Name()] {169+ for _, a := range ws.Addrs() {170+ if r.Contains(a) && a != r.From {171+ ws.EraseRange(sheet.NewRect(a, a))172+ }173+ }174+ }175+ }176+ var buf bytes.Buffer177+ if err := s.Write(&buf); err != nil {178+ t.Fatal(err)179+ }180+ return buf.String()181+}182+183+// mergedCells are the merged ranges of each sheet. excelize reads any184+// cell of a merged range as its first cell, so a formula there spreads185+// to the others (when a cell after them in the row has a value); 012186+// reads each cell as written.187+func mergedCells(t *testing.T, path string) map[string][]sheet.Rect {188+ x, err := excelize.OpenFile(path)189+ if err != nil {190+ t.Fatal(err)191+ }192+ defer x.Close()193+ out := map[string][]sheet.Rect{}194+ for _, ws := range x.GetSheetList() {195+ merged, _ := x.GetMergeCells(ws)196+ for _, m := range merged {197+ if r, ok := sheet.ParseRange(m.GetStartAxis() + ":" + m.GetEndAxis()); ok {198+ out[ws] = append(out[ws], r)199+ }200+ }201+ }202+ return out203+}204+205+func itoa(n int) string { return numInput(float64(n)) }
A internal/fileio/xlsx_excelize_test.go +226 -0
@@ -0,0 +1,226 @@
1+package fileio2+3+// The XLSX importer 012 used before its own reader, on excelize, kept4+// as the reference for TestXLSXDifferential.5+6+import (7+ "context"8+ "fmt"9+ "math"10+ "strconv"11+ "strings"12+13+ "github.com/xuri/excelize/v2"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// importXLSXExcelize reads every sheet of a workbook, with its named ranges, and19+// returns the sheet that was active in Excel.20+func importXLSXExcelize(ctx context.Context, name string, opt Options) (*Result, error) {21+ prog := opt.Progress22+ x, err := excelize.OpenFile(name)23+ if err != nil {24+ return nil, err25+ }26+ defer x.Close()27+ names := x.GetSheetList()28+ if len(names) == 0 {29+ return nil, fmt.Errorf("the workbook has no sheets")30+ }31+ // Rows across all sheets, for the progress bar.32+ dims := make([]int, len(names))33+ total := 034+ for i, ws := range names {35+ if dim, err := x.GetSheetDimension(ws); err == nil {36+ if _, to, ok := strings.Cut(dim, ":"); ok {37+ if _, r, err := excelize.CellNameToCoordinates(to); err == nil {38+ dims[i] = r39+ }40+ }41+ }42+ total += dims[i]43+ }44+45+ b := newBuilder(opt.MaxCells)46+ book := b.s.Book()47+ styles := map[int]xlsxStyle{}48+ var notes []string49+ done := 050+ for i, ws := range names {51+ next := b.s52+ if i == 0 {53+ err = book.RenameSheet(b.s, ws)54+ } else {55+ next, err = book.AddSheet(ws, i)56+ }57+ if err != nil {58+ return nil, fmt.Errorf("sheet %s: %w", ws, err)59+ }60+ b.nextSheet(next)61+ rows, err := excelizeSheet(ctx, x, b, ws, styles, func(row int) {62+ prog.setRows(done + row)63+ prog.setFrac(int64(done+row), int64(total))64+ })65+ if err != nil {66+ return nil, err67+ }68+ done += rows69+ }70+ notes = append(notes, excelizeNames(x, book)...)71+ active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))72+ book.SetActive(active)73+ for i, ws := range names {74+ if shown, err := x.GetSheetVisible(ws); err == nil && !shown && book.Sheet(i) != active {75+ book.HideSheet(book.Sheet(i)) // hidden in Excel, hidden here76+ }77+ }78+ b.s = active79+ prog.setRows(done)80+ s, notes := b.finish(notes)81+ return &Result{Sheet: s, Rows: done, Notes: notes}, nil82+}83+84+// excelizeSheet reads one sheet into b.s, reporting rows read, and85+// returns how many rows it read.86+func excelizeSheet(ctx context.Context, x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle, report func(int)) (int, error) {87+ rows, err := x.Rows(ws)88+ if err != nil {89+ return 0, err90+ }91+ defer rows.Close()92+ row, width := 0, 093+ for ; rows.Next(); row++ {94+ if row%128 == 0 {95+ if err := ctx.Err(); err != nil {96+ return 0, err97+ }98+ report(row)99+ }100+ cols, err := rows.Columns(excelize.Options{RawCellValue: true})101+ if err != nil {102+ return 0, err103+ }104+ if id := rows.GetRowOpts().StyleID; id != 0 {105+ loadRowStyle(b, row, excelizeStyleOf(x, id, styles))106+ }107+ width = max(width, len(cols))108+ for col, raw := range cols {109+ a := sheet.Addr{Col: col, Row: row}110+ if !b.fits(a) {111+ break112+ }113+ cell, _ := excelize.CoordinatesToCellName(col+1, row+1)114+ excelizeCell(x, b, ws, cell, a, raw, styles)115+ }116+ }117+ if err := rows.Error(); err != nil {118+ return 0, err119+ }120+ loadColStyles(b, width, func(c int) (xlsxStyle, bool) {121+ colName, _ := excelize.ColumnNumberToName(c + 1)122+ if id, err := x.GetColStyle(ws, colName); err == nil && id != 0 {123+ st := excelizeStyleOf(x, id, styles)124+ return st, st != (xlsxStyle{})125+ }126+ return xlsxStyle{}, false127+ })128+ for c := range min(max(width, 256), sheet.MaxCols) {129+ colName, _ := excelize.ColumnNumberToName(c + 1)130+ w, err := x.GetColWidth(ws, colName)131+ if err != nil || math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 {132+ continue133+ }134+ b.s.LoadColWidth(c, max(int(math.Round(w))+excelPadding, 1))135+ }136+ return row, nil137+}138+139+// excelizeNames defines the workbook's named ranges that are a range on140+// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet141+// scoped names, constants, formulas).142+func excelizeNames(x *excelize.File, book *sheet.Workbook) []string {143+ skipped := 0144+ example := ""145+ for _, dn := range x.GetDefinedName() {146+ if strings.HasPrefix(dn.Name, "_xlnm.") {147+ continue // print areas and the like148+ }149+ ref := strings.TrimPrefix(dn.RefersTo, "=")150+ ws, rest := sheet.SplitSheet(ref)151+ r, ok := sheet.ParseRange(rest)152+ s := book.Lookup(ws)153+ if dn.Scope != "" && dn.Scope != "Workbook" || !ok || s == nil || book.DefineName(dn.Name, s, r) != nil {154+ skipped++155+ if example == "" {156+ example = dn.Name157+ }158+ }159+ }160+ if skipped > 0 {161+ return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}162+ }163+ return nil164+}165+166+func excelizeStyleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyle {167+ if st, ok := cache[id]; ok {168+ return st169+ }170+ var out xlsxStyle171+ if st, err := x.GetStyle(id); err == nil && st != nil {172+ code := ""173+ if st.CustomNumFmt != nil {174+ code = *st.CustomNumFmt175+ }176+ out.format = formatOf(st.NumFmt, code)177+ if f := st.Font; f != nil {178+ out.style.Bold, out.style.Italic, out.style.Strikethrough = f.Bold, f.Italic, f.Strike179+ out.style.Underline = f.Underline != "" && f.Underline != "none"180+ }181+ if al := st.Alignment; al != nil {182+ switch al.Horizontal {183+ case "left":184+ out.style.Align = sheet.AlignLeft185+ case "center", "centerContinuous":186+ out.style.Align = sheet.AlignCenter187+ case "right":188+ out.style.Align = sheet.AlignRight189+ }190+ }191+ }192+ cache[id] = out193+ return out194+}195+196+func excelizeCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr, raw string, styles map[int]xlsxStyle) {197+ var st xlsxStyle198+ if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 {199+ st = excelizeStyleOf(x, id, styles)200+ }201+ typ, _ := x.GetCellType(ws, cell)202+ keep := func() {203+ switch typ {204+ case excelize.CellTypeBool:205+ b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)206+ return207+ case excelize.CellTypeSharedString, excelize.CellTypeInlineString, excelize.CellTypeFormula, excelize.CellTypeError:208+ b.text(a, raw, st.format, st.style)209+ return210+ }211+ if v, err := strconv.ParseFloat(raw, 64); err == nil {212+ b.number(a, fromExcelSerial(v, st.format), st.format, st.style)213+ return214+ }215+ b.text(a, raw, st.format, st.style)216+ }217+ if f, err := x.GetCellFormula(ws, cell); err == nil && f != "" {218+ b.formula(a, fromExcelFormula(f), st.format, st.style, keep)219+ return220+ }221+ if raw == "" {222+ b.put(a, "", st.format, st.style)223+ return224+ }225+ keep()226+}
M internal/fileio/xlsx_test.go +148 -0
@@ -328,3 +328,151 @@ func TestXLSXWorkbookRoundTrip(t *testing.T) {
328328 t.Errorf("name Rent: %v %s", ok, n.Ref())329329 }330330 }331+332+// Text that XML can't hold, or that reads as one of SpreadsheetML's333+// escapes, goes out escaped and comes back the same, through excelize334+// and 012; text past Excel's limit is cut.335+func TestXLSXWriteText(t *testing.T) {336+ src := sheet.New()337+ long := strings.Repeat("x", excelMaxText+10)338+ want := map[string]string{339+ "A1": "bell\x07 and nul\x00", "A2": "_x0041_ stays", "A3": `<&> "quoted" 'too'`,340+ "A4": "cr\rend", "A5": "￾ noncharacter", "A6": long, "A7": "emoji \U0001F642",341+ }342+ for cell, text := range want {343+ if err := src.Load(addr(t, cell), "'"+text, sheet.Format{}, sheet.Style{}); err != nil {344+ t.Fatal(err)345+ }346+ }347+ src.RecalcAll()348+ for cell := range want {349+ want[cell] = src.Value(addr(t, cell)).Str // as 012 keeps it350+ }351+ want["A6"] = long[:excelMaxText]352+ name := filepath.Join(t.TempDir(), "text.xlsx")353+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {354+ t.Fatal(err)355+ }356+ x, err := excelize.OpenFile(name)357+ if err != nil {358+ t.Fatal(err)359+ }360+ defer x.Close()361+ for cell, text := range want {362+ if got, _ := x.GetCellValue("Sheet1", cell); got != text {363+ t.Errorf("excelize reads %s as %q, want %q", cell, trim(got), trim(text))364+ }365+ }366+ res, err := Import(context.Background(), name, Options{})367+ if err != nil {368+ t.Fatal(err)369+ }370+ back := res.Sheet.Book().Sheet(0)371+ for cell, text := range want {372+ if got := input(back, addr(t, cell)); got != flatten(text) {373+ t.Errorf("012 reads %s as %q, want %q", cell, trim(got), trim(text))374+ }375+ }376+}377+378+// A sheet whose name Excel can't take as is gets another; formulas379+// naming it go out as values, like formulas naming a missing sheet.380+func TestXLSXRenamedSheetAsValue(t *testing.T) {381+ src := build(t, map[string]string{"A1": "1"})382+ book := src.Book()383+ book.RenameSheet(src, "Plan")384+ spaced, err := book.AddSheet(" Plan", 1)385+ if err != nil {386+ t.Fatal(err)387+ }388+ spaced.Set(addr(t, "A1"), "=' Plan'!A2+Plan!A1")389+ name := filepath.Join(t.TempDir(), "renamed.xlsx")390+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})391+ if err != nil {392+ t.Fatal(err)393+ }394+ want := "1 formula naming a sheet that doesn't exist saved as values, e.g. 'Plan (2)'!A1 (' Plan')"395+ if len(res.Notes) != 1 || res.Notes[0] != want {396+ t.Errorf("notes %q, want %q", res.Notes, want)397+ }398+}399+400+func TestUniqueSheetName(t *testing.T) {401+ used := map[string]bool{}402+ long := strings.Repeat("x", 31)403+ for _, tc := range [][2]string{{"Plan", "Plan"}, {"plan", "plan (2)"}, {"Plan", "Plan (3)"}, {long, long}, {long, long[:27] + " (2)"}} {404+ if got := uniqueSheetName(tc[0], used); got != tc[1] {405+ t.Errorf("uniqueSheetName(%q) = %q, want %q", tc[0], got, tc[1])406+ }407+ }408+}409+410+func trim(s string) string {411+ if len(s) > 40 {412+ return s[:40] + "..."413+ }414+ return s415+}416+417+// A formula naming a sheet the workbook doesn't have goes out as its418+// value (#REF!), with a note, since Excel would refuse the reference.419+func TestXLSXMissingSheetAsValue(t *testing.T) {420+ src := build(t, map[string]string{"A1": "2", "B1": "=Gone!A1+1", "B2": "=A1*2", "B3": "=SUM('Old plan'!A1:A3, gone!B1)"})421+ name := filepath.Join(t.TempDir(), "missing.xlsx")422+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})423+ if err != nil {424+ t.Fatal(err)425+ }426+ want := "2 formulas naming a sheet that doesn't exist saved as values, e.g. B1 (Gone)"427+ if len(res.Notes) != 1 || res.Notes[0] != want {428+ t.Errorf("notes %q, want %q", res.Notes, want)429+ }430+ x, err := excelize.OpenFile(name)431+ if err != nil {432+ t.Fatal(err)433+ }434+ defer x.Close()435+ ws := x.GetSheetName(0)436+ for cell, formula := range map[string]string{"B1": "", "B2": "A1*2", "B3": ""} {437+ if got, _ := x.GetCellFormula(ws, cell); got != formula {438+ t.Errorf("%s formula %q, want %q", cell, got, formula)439+ }440+ }441+ if got, _ := x.GetCellValue(ws, "B1"); got != "#REF!" {442+ t.Errorf("B1 value %q", got)443+ }444+}445+446+// Hidden sheets go out to Excel hidden, and come back hidden.447+func TestXLSXHiddenSheets(t *testing.T) {448+ src := build(t, map[string]string{"A1": "=Data!A1*2"})449+ book := src.Book()450+ data, _ := book.AddSheet("Data", 1)451+ data.Set(addr(t, "A1"), "21")452+ if err := book.HideSheet(data); err != nil {453+ t.Fatal(err)454+ }455+ name := filepath.Join(t.TempDir(), "hidden.xlsx")456+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {457+ t.Fatal(err)458+ }459+ x, err := excelize.OpenFile(name)460+ if err != nil {461+ t.Fatal(err)462+ }463+ if shown, _ := x.GetSheetVisible("Data"); shown {464+ t.Error("Data is visible in Excel")465+ }466+ if shown, _ := x.GetSheetVisible(x.GetSheetName(0)); !shown {467+ t.Error("the first sheet is hidden in Excel")468+ }469+ x.Close()470+ got, err := Import(context.Background(), name, Options{})471+ if err != nil {472+ t.Fatal(err)473+ }474+ gb := got.Sheet.Book()475+ if !gb.Lookup("Data").Hidden() || got.Sheet.Hidden() || shown(got.Sheet, addr(t, "A1")) != "42" {476+ t.Errorf("read back: Data hidden %v, shown %s hidden %v", gb.Lookup("Data").Hidden(), got.Sheet.Name(), got.Sheet.Hidden())477+ }478+}
A internal/fileio/xlsxbook.go +297 -0
@@ -0,0 +1,297 @@
1+package fileio2+3+import (4+ "bytes"5+ "encoding/xml"6+ "fmt"7+ "io"8+ "path"9+ "strconv"10+)11+12+// xlsxBook is an open workbook: its sheets, names, shared strings and13+// cell styles. Worksheets are streamed one at a time by readSheet.14+type xlsxBook struct {15+ pkg *xlsxPackage16+ sheets []xlsxSheetInfo17+ active int // index of the sheet shown in Excel18+ names []xlsxName19+ date1904 bool20+ sst sharedStrings21+ styles xlsxStyles22+ expanded int64 // bytes of shared formulas expanded, see formula23+}24+25+// xlsxSheetInfo is a sheet as the workbook lists it.26+type xlsxSheetInfo struct {27+ name string28+ id int // sheetId29+ part string // the worksheet part, "" when missing30+ kind string // the relationship type: worksheet, chartsheet...31+ hidden bool // state hidden or veryHidden32+}33+34+// xlsxName is a defined name: sheet is the index of the sheet it is35+// scoped to, or -1 for the workbook.36+type xlsxName struct {37+ name, refersTo string38+ sheet int39+}40+41+// openXLSX opens a workbook and reads everything but its worksheets.42+func openXLSX(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxBook, error) {43+ p, err := openXLSXPackage(r, size, lim)44+ if err != nil {45+ return nil, err46+ }47+ bk := &xlsxBook{pkg: p}48+ wb := "xl/workbook.xml"49+ root, err := p.rels("")50+ if err != nil {51+ return nil, err52+ }53+ for _, rel := range root {54+ if rel.typ == "officeDocument" && p.has(rel.target) {55+ wb = rel.target56+ }57+ }58+ rels, err := p.rels(wb)59+ if err != nil {60+ return nil, err61+ }62+ ids, err := bk.readWorkbook(wb)63+ if err != nil {64+ return nil, err65+ }66+ dir := path.Dir(wb) + "/"67+ sst, styles := dir+"sharedStrings.xml", dir+"styles.xml"68+ for _, rel := range rels {69+ switch rel.typ {70+ case "sharedStrings":71+ sst = rel.target72+ case "styles":73+ styles = rel.target74+ }75+ }76+ for i, id := range ids {77+ if rel, ok := rels[id]; ok && p.has(rel.target) {78+ bk.sheets[i].part, bk.sheets[i].kind = rel.target, rel.typ79+ }80+ }81+ if err := bk.sst.read(p, sst); err != nil {82+ return nil, err83+ }84+ if err := bk.styles.read(p, styles); err != nil {85+ return nil, err86+ }87+ return bk, nil88+}89+90+// readWorkbook reads the workbook part: its sheets (returning their91+// relationship ids), the active tab, defined names and date system.92+func (bk *xlsxBook) readWorkbook(part string) (ids []string, err error) {93+ x, err := bk.pkg.open(part)94+ if err != nil {95+ return nil, err96+ }97+ if x == nil {98+ return nil, fmt.Errorf("not an Excel workbook: no %s", part)99+ }100+ defer x.close()101+ activeTab, views := 0, 0102+ for {103+ t, err := x.next()104+ if err == io.EOF {105+ break106+ }107+ if err != nil {108+ return nil, err109+ }110+ se, ok := t.(xml.StartElement)111+ if !ok {112+ continue113+ }114+ switch se.Name.Local {115+ case "sheet":116+ if len(bk.sheets) >= bk.pkg.lim.sheets {117+ return nil, fmt.Errorf("the workbook has more than %d sheets: %w", bk.pkg.lim.sheets, errXLSXLimit)118+ }119+ name, _ := attr(se, "name")120+ id, _ := attr(se, "sheetId")121+ state, _ := attr(se, "state")122+ n, _ := strconv.Atoi(id)123+ bk.sheets = append(bk.sheets, xlsxSheetInfo{name: name, id: n, hidden: state == "hidden" || state == "veryHidden"})124+ ids = append(ids, relAttr(se))125+ case "workbookView":126+ if views++; views == 1 {127+ v, _ := attr(se, "activeTab")128+ activeTab, _ = strconv.Atoi(v)129+ }130+ case "workbookPr":131+ v, _ := attr(se, "date1904")132+ bk.date1904 = v == "1" || v == "true"133+ case "definedName":134+ if err := bk.readName(x, se); err != nil {135+ return nil, err136+ }137+ }138+ }139+ bk.active = bk.activeIndex(activeTab)140+ return ids, nil141+}142+143+// activeIndex is the index of the sheet Excel showed, as excelize finds144+// it: the active tab's sheet by its sheetId, else the first.145+func (bk *xlsxBook) activeIndex(tab int) int {146+ if len(bk.sheets) == 0 {147+ return 0148+ }149+ id := bk.sheets[0].id150+ if tab >= 0 && tab < len(bk.sheets) && bk.sheets[tab].id != 0 {151+ id = bk.sheets[tab].id152+ }153+ for i, s := range bk.sheets {154+ if s.id == id {155+ return i156+ }157+ }158+ return 0159+}160+161+func (bk *xlsxBook) readName(x *xmlStream, se xml.StartElement) error {162+ if len(bk.names) >= bk.pkg.lim.styles {163+ return fmt.Errorf("the workbook has more than %d names: %w", bk.pkg.lim.styles, errXLSXLimit)164+ }165+ n := xlsxName{sheet: -1}166+ n.name, _ = attr(se, "name")167+ if v, ok := attr(se, "localSheetId"); ok {168+ if id, err := strconv.Atoi(v); err == nil && id >= 0 {169+ n.sheet = id170+ }171+ }172+ text, err := x.text(nil)173+ n.refersTo = string(text)174+ bk.names = append(bk.names, n)175+ return err176+}177+178+// sharedStrings holds the workbook's shared strings end to end in one179+// buffer, so a table of many short strings costs its text and four180+// bytes a string rather than a string header and allocation each.181+type sharedStrings struct {182+ buf []byte183+ ends []uint32184+}185+186+func (s *sharedStrings) get(i int) (string, bool) {187+ if i < 0 || i >= len(s.ends) {188+ return "", false189+ }190+ from := uint32(0)191+ if i > 0 {192+ from = s.ends[i-1]193+ }194+ return string(s.buf[from:s.ends[i]]), true195+}196+197+// read streams the shared strings part: each <si> is its <t> text, or198+// the text of its rich-text runs (<r><t>), leaving out phonetic hints.199+func (s *sharedStrings) read(p *xlsxPackage, part string) error {200+ x, err := p.open(part)201+ if x == nil || err != nil {202+ return err203+ }204+ defer x.close()205+ var item []byte206+ for {207+ t, err := x.next()208+ if err == io.EOF {209+ return nil210+ }211+ if err != nil {212+ return err213+ }214+ se, ok := t.(xml.StartElement)215+ if !ok || se.Name.Local != "si" {216+ continue217+ }218+ if item, err = inlineText(x, item[:0]); err != nil {219+ return err220+ }221+ if len(s.ends) >= p.lim.strings || len(s.buf)+len(item) > 1<<32-1 {222+ return fmt.Errorf("the workbook has more than %d shared strings: %w", p.lim.strings, errXLSXLimit)223+ }224+ s.buf = append(s.buf, unescapeOOXML(item)...)225+ s.ends = append(s.ends, uint32(len(s.buf)))226+ }227+}228+229+// inlineText reads the rest of an <si> or <is> element: the text of its230+// <t> children and of the <t> in its <r> runs, not in <rPh> hints.231+func inlineText(x *xmlStream, buf []byte) ([]byte, error) {232+ depth := x.depth233+ inT, skipDepth := false, 0234+ for {235+ t, err := x.next()236+ if err != nil {237+ return buf, eofAsUnexpected(err)238+ }239+ switch t := t.(type) {240+ case xml.StartElement:241+ switch {242+ case skipDepth > 0:243+ case t.Name.Local == "rPh" || t.Name.Local == "phoneticPr":244+ skipDepth = x.depth245+ case t.Name.Local == "t":246+ inT = true247+ }248+ case xml.EndElement:249+ if x.depth < depth {250+ return buf, nil251+ }252+ if skipDepth > 0 && x.depth < skipDepth {253+ skipDepth = 0254+ }255+ inT = false256+ case xml.CharData:257+ if inT && skipDepth == 0 {258+ buf = append(buf, t...)259+ }260+ }261+ }262+}263+264+// unescapeOOXML decodes _xHHHH_, how OOXML writes characters XML can't265+// hold (_x000D_ for a carriage return), and _x005F_, an escaped _.266+func unescapeOOXML(b []byte) []byte {267+ if !bytes.Contains(b, []byte("_x")) {268+ return b269+ }270+ out := make([]byte, 0, len(b))271+ for i := 0; i < len(b); {272+ if r, ok := ooxmlEscape(b[i:]); ok {273+ if r == '_' {274+ out = append(out, '_')275+ } else {276+ out = append(out, string(r)...)277+ }278+ i += 7279+ continue280+ }281+ out = append(out, b[i])282+ i++283+ }284+ return out285+}286+287+// ooxmlEscape decodes the _xHHHH_ at the start of b.288+func ooxmlEscape(b []byte) (rune, bool) {289+ if len(b) < 7 || b[0] != '_' || b[1] != 'x' || b[6] != '_' {290+ return 0, false291+ }292+ v, err := strconv.ParseUint(string(b[2:6]), 16, 16)293+ if err != nil {294+ return 0, false295+ }296+ return rune(v), true297+}
A internal/fileio/xlsximport.go +294 -0
@@ -0,0 +1,294 @@
1+package fileio2+3+import (4+ "context"5+ "fmt"6+ "math"7+ "os"8+ "slices"9+ "strconv"10+ "strings"11+ "time"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// importXLSX reads every sheet of a workbook, with its named17+// ranges, and returns the sheet that was active in Excel. Worksheets are18+// streamed a row at a time (see xlsxpkg.go).19+func importXLSX(ctx context.Context, name string, opt Options) (*Result, error) {20+ f, err := os.Open(name)21+ if err != nil {22+ return nil, err23+ }24+ defer f.Close()25+ st, err := f.Stat()26+ if err != nil {27+ return nil, err28+ }29+ bk, err := openXLSX(f, st.Size(), defaultXLSXLimits)30+ if err != nil {31+ return nil, err32+ }33+ return bk.importBook(ctx, opt)34+}35+36+func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error) {37+ prog := opt.Progress38+ if len(bk.sheets) == 0 {39+ return nil, fmt.Errorf("the workbook has no sheets")40+ }41+ // Rows across all sheets, for the progress bar.42+ total := 043+ for i := range bk.sheets {44+ total += bk.dimension(i)45+ }46+ b := newBuilder(opt.MaxCells)47+ book := b.s.Book()48+ done := 049+ var err error50+ for i, info := range bk.sheets {51+ next := b.s52+ if i == 0 {53+ err = book.RenameSheet(b.s, info.name)54+ } else {55+ next, err = book.AddSheet(info.name, i)56+ }57+ if err != nil {58+ return nil, fmt.Errorf("sheet %s: %w", info.name, err)59+ }60+ b.nextSheet(next)61+ rows, err := bk.importSheet(ctx, b, i, func(row int) {62+ prog.setRows(done + row)63+ prog.setFrac(int64(done+row), int64(total))64+ })65+ if err != nil {66+ return nil, err67+ }68+ done += rows69+ }70+ notes := bk.importNames(book)71+ active := book.Sheet(clamp(bk.active, 0, book.Len()-1))72+ book.SetActive(active)73+ for i, info := range bk.sheets {74+ if info.hidden && book.Sheet(i) != active {75+ book.HideSheet(book.Sheet(i)) // hidden in Excel, hidden here76+ }77+ }78+ b.s = active79+ prog.setRows(done)80+ s, notes := b.finish(notes)81+ return &Result{Sheet: s, Rows: done, Notes: notes}, nil82+}83+84+// importSheet reads sheet i into b.s, reporting rows read, and returns85+// the number of its last row.86+func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, report func(int)) (int, error) {87+ r, err := bk.openSheet(i)88+ if err != nil {89+ return 0, err90+ }91+ defer r.close()92+ last, width := 0, 093+ for n := 0; ; n++ {94+ ok, err := r.next()95+ if err != nil {96+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)97+ }98+ if !ok {99+ break100+ }101+ if n%128 == 0 {102+ if err := ctx.Err(); err != nil {103+ return 0, err104+ }105+ report(r.row.num)106+ }107+ if err := bk.importRow(b, r); err != nil {108+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)109+ }110+ last = max(last, r.row.num)111+ if n := len(r.row.cells); n > 0 {112+ width = max(width, r.row.cells[n-1].col)113+ }114+ }115+ loadColStyles(b, width, func(c int) (xlsxStyle, bool) {116+ st := bk.styles.style(r.lineStyle(c + 1))117+ return st, st != (xlsxStyle{})118+ })119+ for c := range min(max(width, 256), sheet.MaxCols) { // the data's columns, and 1-2-3's120+ w := r.colWidth(c + 1)121+ if math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 || !(w > 0 && w < 1000) {122+ continue123+ }124+ b.s.LoadColWidth(c, max(int(math.Round(w))+excelPadding, 1))125+ }126+ return last, nil127+}128+129+// importRow stores a row's cells. As Excel's cells are read, a cell130+// with neither a value nor a formula only counts for its format, and131+// only up to the row's last cell with one: formatted blanks after it are132+// left out.133+func (bk *xlsxBook) importRow(b *builder, r *xlsxSheetReader) error {134+ row := &r.row135+ if row.style != 0 {136+ loadRowStyle(b, row.num-1, bk.styles.style(row.style))137+ }138+ cells := row.cells139+ if !slices.IsSortedFunc(cells, func(a, b xlsxCell) int { return a.col - b.col }) {140+ slices.SortStableFunc(cells, func(a, b xlsxCell) int { return a.col - b.col })141+ }142+ lastCol := 0143+ for i := range cells {144+ if cells[i].kept() {145+ lastCol = cells[i].col146+ }147+ }148+ if lastCol == 0 || !b.fits(sheet.Addr{Col: lastCol - 1, Row: row.num - 1}) && row.num > sheet.MaxRows {149+ return nil150+ }151+ j := 0152+ for col := 1; col <= min(lastCol, sheet.MaxCols); col++ {153+ a := sheet.Addr{Col: col - 1, Row: row.num - 1}154+ for j < len(cells) && cells[j].col < col {155+ j++156+ }157+ var c *xlsxCell158+ id := 0159+ if j < len(cells) && cells[j].col == col {160+ c = &cells[j]161+ id = c.style162+ }163+ if id == 0 {164+ id = row.style165+ }166+ if id == 0 {167+ id = r.colStyle(col)168+ }169+ st := bk.styles.style(id)170+ if c == nil || !c.kept() {171+ b.put(a, "", st.format, st.style)172+ continue173+ }174+ f, err := r.formula(c)175+ if err != nil {176+ return err177+ }178+ bk.importCell(b, a, c, f, st)179+ }180+ return nil181+}182+183+func (bk *xlsxBook) importCell(b *builder, a sheet.Addr, c *xlsxCell, formula string, st xlsxStyle) {184+ if formula != "" {185+ b.formula(a, fromExcelFormula(formula), st.format, st.style, func() { bk.keep(b, a, c, st) })186+ return187+ }188+ if c.value == "" {189+ b.put(a, "", st.format, st.style)190+ return191+ }192+ bk.keep(b, a, c, st)193+}194+195+// keep stores a cell's value: its type says whether it is a boolean or196+// text; anything else that reads as a number is one.197+func (bk *xlsxBook) keep(b *builder, a sheet.Addr, c *xlsxCell, st xlsxStyle) {198+ raw := c.value199+ switch c.typ {200+ case "b":201+ b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)202+ return203+ case "s", "inlineStr", "str", "e":204+ b.text(a, raw, st.format, st.style)205+ return206+ case "d":207+ if v, f, ok := isoDate(raw); ok {208+ if st.format.IsZero() {209+ st.format = f210+ }211+ b.number(a, v, st.format, st.style)212+ return213+ }214+ }215+ if v, err := strconv.ParseFloat(raw, 64); err == nil {216+ b.number(a, bk.serial(v, st.format), st.format, st.style)217+ return218+ }219+ b.text(a, raw, st.format, st.style)220+}221+222+// serial converts an Excel serial number to 012's: in the 1904 date223+// system, day 0 is January 1, 1904.224+func (bk *xlsxBook) serial(v float64, f sheet.Format) float64 {225+ if bk.date1904 && isDateFormat(f) {226+ return v + date1904Offset227+ }228+ return fromExcelSerial(v, f)229+}230+231+// date1904Offset is the serial of January 1, 1904 in 012's (and the 1900232+// system's) count.233+const date1904Offset = 1462234+235+// isoDate reads a t="d" cell's ISO 8601 date, time or both as 012's236+// serial number, with the format that shows it.237+func isoDate(s string) (float64, sheet.Format, bool) {238+ s = strings.TrimSpace(s)239+ for _, l := range []struct {240+ layout string241+ kind sheet.FormatKind242+ }{243+ {"2006-01-02T15:04:05.999999999Z07:00", sheet.FmtDateTime},244+ {"2006-01-02T15:04:05.999999999", sheet.FmtDateTime},245+ {"2006-01-02", sheet.FmtDate},246+ {"T15:04:05.999999999", sheet.FmtTime},247+ {"15:04:05.999999999", sheet.FmtTime},248+ } {249+ t, err := time.Parse(l.layout, s)250+ if err != nil {251+ continue252+ }253+ wall := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)254+ base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)255+ if l.kind == sheet.FmtTime {256+ base = time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC)257+ }258+ return wall.Sub(base).Hours() / 24, sheet.Format{Kind: l.kind}, true259+ }260+ return 0, sheet.Format{}, false261+}262+263+// importNames defines the workbook's named ranges that are a range on264+// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet265+// scoped names, constants, formulas).266+func (bk *xlsxBook) importNames(book *sheet.Workbook) []string {267+ skipped := 0268+ example := ""269+ for _, dn := range bk.names {270+ if strings.HasPrefix(dn.name, "_xlnm.") {271+ continue // print areas and the like272+ }273+ ws, rest := sheet.SplitSheet(strings.TrimPrefix(dn.refersTo, "="))274+ r, ok := sheet.ParseRange(rest)275+ s := book.Lookup(ws)276+ if bk.scoped(dn) || !ok || s == nil || book.DefineName(dn.name, s, r) != nil {277+ skipped++278+ if example == "" {279+ example = dn.name280+ }281+ }282+ }283+ if skipped > 0 {284+ return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}285+ }286+ return nil287+}288+289+// scoped reports whether a name belongs to one sheet. As excelize has290+// it, a name scoped to a sheet that doesn't exist, or to one called291+// Workbook, is the workbook's.292+func (bk *xlsxBook) scoped(dn xlsxName) bool {293+ return dn.sheet >= 0 && dn.sheet < len(bk.sheets) && bk.sheets[dn.sheet].name != "Workbook"294+}
M internal/fileio/xlsxlines.go +70 -73
@@ -1,17 +1,19 @@
11 package fileio22 33 import (4+ "bufio"5+ "fmt"46 "maps"5- "math"67 "slices"78 8- "github.com/xuri/excelize/v2"9-109 "github.com/FelineStateMachine/012/internal/sheet"1110 )1211 13-// Column and row styles: Excel keeps a style for a whole column (in14-// <cols>) or row, as 012 keeps line formats; both travel both ways.12+// Column and row styles: Excel keeps a style for whole columns (in13+// <cols>) and rows (a <row>'s s, with customFormat), as 012 keeps line14+// formats; both travel both ways. Excel has no style for the whole15+// sheet: 012's goes out on every column, and a style on both A and XFD16+// comes back as the sheet's.1517 1618 // LineFormat is the format and style of a whole column or row.1719 type LineFormat struct {
@@ -19,9 +21,8 @@ type LineFormat struct {
1921 Style sheet.Style2022 }2123 22-// snapLines copies a sheet's column (row false) or row formats. Excel23-// has no format for the whole sheet, so with one every column gets it,24-// under its own.24+// snapLines copies a sheet's column (row false) or row formats, the25+// sheet's own format on every column.2526 func snapLines(s *sheet.Sheet, row bool) map[int]LineFormat {2627 fs, ss := s.ColFormats(), s.ColStyles()2728 if row {
@@ -46,95 +47,91 @@ func snapLines(s *sheet.Sheet, row bool) map[int]LineFormat {
4647 return out4748 }4849 49-// importXLSXColumns reads the widths and styles of the sheet's columns50-// up to its data's width (at least 256); a style on every column, the51-// last included, is the sheet's.52-func importXLSXColumns(x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle, width int) {53- styleOfCol := func(name string) (xlsxStyle, bool) {54- if id, err := x.GetColStyle(ws, name); err == nil && id != 0 {55- st := styleOf(x, id, styles)56- return st, st != (xlsxStyle{})57- }58- return xlsxStyle{}, false59- }50+// loadColStyles gives the builder's sheet the styles of its columns, as51+// styleOf (by column from 0) reports them: the sheet's when A and XFD52+// have the same one, and those of the first width columns (at least53+// 256) that differ from it.54+func loadColStyles(b *builder, width int, styleOf func(col int) (xlsxStyle, bool)) {6055 var whole xlsxStyle61- if first, ok := styleOfCol("A"); ok {62- if last, _ := styleOfCol("XFD"); last == first {56+ if first, ok := styleOf(0); ok {57+ if last, _ := styleOf(sheet.MaxCols - 1); last == first {6358 whole = first6459 b.s.LoadLineFormat(false, -1, first.format, first.style)6560 }6661 }67- cols := map[int]xlsxStyle{}6862 for c := range min(max(width, 256), sheet.MaxCols) {69- colName, _ := excelize.ColumnNumberToName(c + 1)70- if w, err := x.GetColWidth(ws, colName); err == nil && math.Abs(w-excelDefaultWidth) >= 0.01 && math.Abs(w-9.140625) >= 0.01 {71- b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))72- }73- if st, ok := styleOfCol(colName); ok && st != whole {74- cols[c] = st63+ if st, ok := styleOf(c); ok && st != whole {64+ b.s.LoadLineFormat(false, c, st.format, st.style)7565 }7666 }77- for c, st := range cols {78- b.s.LoadLineFormat(false, c, st.format, st.style)79- }8067 }8168 82-// importXLSXRowStyle keeps the style of row, if it has one of its own.83-func importXLSXRowStyle(x *excelize.File, b *builder, rows *excelize.Rows, row int, styles map[int]xlsxStyle) {84- if id := rows.GetRowOpts().StyleID; id != 0 && row < sheet.MaxRows {85- if st := styleOf(x, id, styles); st != (xlsxStyle{}) {86- b.s.LoadLineFormat(true, row, st.format, st.style)87- }69+// loadRowStyle gives row (from 0) of the builder's sheet its style.70+func loadRowStyle(b *builder, row int, st xlsxStyle) {71+ if st != (xlsxStyle{}) && row >= 0 && row < sheet.MaxRows {72+ b.s.LoadLineFormat(true, row, st.format, st.style)8873 }8974 }9075 91-// colStyles sets the snapshot's column styles on a stream, before any92-// row: runs of columns with the same style as one range.93-func (w *xlsxWriter) colStyles(sw *excelize.StreamWriter, cols map[int]LineFormat) error {94- ns := slices.Sorted(maps.Keys(cols))95- for i := 0; i < len(ns); {76+// colSpec is what <cols> says of a column: its width (0 for the77+// default) and style.78+type colSpec struct {79+ width int80+ style int81+}82+83+// writeCols writes <cols>: the columns with a width or a format, runs84+// of columns alike as one <col>.85+func (w *xlsxWriter) writeCols(bw *bufio.Writer, snap *Snapshot) {86+ specs := map[int]colSpec{}87+ for c, width := range snap.Widths {88+ specs[c] = colSpec{width: max(width-excelPadding, 1)}89+ }90+ for c, l := range snap.ColFormats {91+ sp := specs[c]92+ sp.style = w.styles.id(l.Format, l.Style)93+ specs[c] = sp94+ }95+ if len(specs) == 0 {96+ return97+ }98+ bw.WriteString(`<cols>`)99+ cs := slices.Sorted(maps.Keys(specs))100+ for i := 0; i < len(cs); {96101 j := i + 197- for j < len(ns) && ns[j] == ns[j-1]+1 && cols[ns[j]] == cols[ns[i]] {102+ for j < len(cs) && cs[j] == cs[j-1]+1 && specs[cs[j]] == specs[cs[i]] {98103 j++99104 }100- l := cols[ns[i]]101- id, err := w.styleFor(l.Format, l.Style)102- if err != nil {103- return err105+ sp := specs[cs[i]]106+ fmt.Fprintf(bw, `<col min="%d" max="%d"`, cs[i]+1, cs[j-1]+1)107+ if sp.width > 0 {108+ fmt.Fprintf(bw, ` width="%d" customWidth="1"`, sp.width)104109 }105- if err := sw.SetColStyle(ns[i]+1, ns[j-1]+1, id); err != nil {106- return err110+ if sp.style > 0 {111+ fmt.Fprintf(bw, ` style="%d"`, sp.style)107112 }113+ bw.WriteString(`/>`)108114 i = j109115 }110- return nil116+ bw.WriteString(`</cols>`)111117 }112118 113-// rowOpts is how the stream writes row: with its style, if it has one.114-func (w *xlsxWriter) rowOpts(rows map[int]LineFormat, row int) ([]excelize.RowOpts, error) {115- l, ok := rows[row]116- if !ok {117- return nil, nil119+// rowStart writes a <row> start tag, with the row's style if it has one.120+func (w *xlsxWriter) rowStart(bw *bufio.Writer, snap *Snapshot, row int) {121+ fmt.Fprintf(bw, `<row r="%d"`, row+1)122+ if l, ok := snap.RowFormats[row]; ok {123+ fmt.Fprintf(bw, ` s="%d" customFormat="1"`, w.styles.id(l.Format, l.Style))118124 }119- id, err := w.styleFor(l.Format, l.Style)120- return []excelize.RowOpts{{StyleID: id}}, err125+ bw.WriteString(`>`)121126 }122127 123-// styledRowsAfter writes the rows past the exported range that have a124-// style of their own, as empty rows.125-func (w *xlsxWriter) styledRowsAfter(sw *excelize.StreamWriter, rows map[int]LineFormat, last int) error {126- for _, row := range slices.Sorted(maps.Keys(rows)) {127- if row <= last {128- continue129- }130- opts, err := w.rowOpts(rows, row)131- if err != nil {132- return err133- }134- cell, _ := excelize.CoordinatesToCellName(1, row+1)135- if err := sw.SetRow(cell, nil, opts...); err != nil {136- return err137- }128+// styledRows writes, as empty rows, the styled rows (ascending) before129+// row to, returning those left.130+func (w *xlsxWriter) styledRows(bw *bufio.Writer, snap *Snapshot, rows []int, to int) []int {131+ for len(rows) > 0 && rows[0] < to {132+ w.rowStart(bw, snap, rows[0])133+ bw.WriteString(`</row>`)134+ rows = rows[1:]138135 }139- return nil136+ return rows140137 }
A internal/fileio/xlsxparts_test.go +105 -0
@@ -0,0 +1,105 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "bytes"6+ "maps"7+ "os"8+ "path/filepath"9+ "slices"10+ "testing"11+)12+13+// Hand-written SpreadsheetML for tests of the XLSX reader: what Excel14+// writes and excelize doesn't (shared formulas, inline and rich text,15+// row and column styles, cells without references).16+17+const (18+ contentTypes = `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>`19+ rootRels = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`20+ bookRels = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`21+ mainNS = `xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`22+23+ kitchenBook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>24+<workbook ` + mainNS + `><workbookPr defaultThemeVersion="124226"/><bookViews><workbookView activeTab="2"/></bookViews><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Hidden" sheetId="2" state="hidden" r:id="rId2"/><sheet name="Refs" sheetId="5" r:id="rId3"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">Data!$A$1:$F$8</definedName><definedName name="Total">Data!$A$2:$B$2</definedName><definedName name="Local" localSheetId="0">Data!$A$1</definedName><definedName name="Const">42</definedName></definedNames><calcPr calcId="152511"/></workbook>`25+26+ kitchenStrings = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>27+<sst ` + mainNS + ` count="6" uniqueCount="6"><si><t>Region</t></si><si><r><rPr><b/></rPr><t xml:space="preserve">Rich </t></r><r><t>text</t></r><rPh sb="0" eb="1"><t>hint</t></rPh></si><si><t>line_x000D_break _x005F_x0041_ tab&#9;end</t></si><si><t>=not a formula</t></si><si><t>123</t></si><si><t/></si></sst>`28+29+ kitchenStyles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>30+<styleSheet ` + mainNS + `><numFmts count="2"><numFmt numFmtId="164" formatCode="0.000"/><numFmt numFmtId="165" formatCode="yyyy\-mm\-dd"/></numFmts><fonts count="5"><font><sz val="11"/></font><font><b/><sz val="11"/></font><font><i/><u/></font><font><strike/><u val="none"/></font><font><b val="0"/></font></fonts><cellStyleXfs count="1"><xf numFmtId="0" fontId="1"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0"/><xf numFmtId="4" fontId="1" applyFont="1" applyNumberFormat="1"/><xf numFmtId="164" fontId="0"/><xf numFmtId="165" fontId="0"/><xf numFmtId="0" fontId="2"><alignment horizontal="center"/></xf><xf numFmtId="10" fontId="3" applyAlignment="0"><alignment horizontal="right"/></xf><xf numFmtId="5" fontId="0"/><xf numFmtId="0" fontId="4"><alignment horizontal="centerContinuous"/></xf><xf numFmtId="14" fontId="1" applyFont="0"><alignment horizontal="left"/></xf></cellXfs></styleSheet>`31+32+ kitchenData = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>33+<worksheet ` + mainNS + `><dimension ref="A1:F9"/><sheetFormatPr defaultColWidth="10" defaultRowHeight="15"/><cols><col min="1" max="1" width="20" customWidth="1"/><col min="3" max="5" width="5" style="2" customWidth="1"/><col min="6" max="6" style="4"/><col min="5" max="5" width="30"/></cols><sheetData>` +34+ `<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="C1" t="inlineStr"><is><t>inline</t></is></c><c r="D1" t="b"><v>1</v></c><c r="E1" t="e"><v>#N/A</v></c><c r="F1" t="str"><f>A1&amp;"x"</f><v>Regionx</v></c></row>` +35+ `<row r="2" s="3" customFormat="1"><c><v>1.5</v></c><c s="2"><v>2</v></c><c r="E2" s="8"><v>45000</v></c></row>` +36+ `<row r="3"><c r="A3"><f>SUM(A2:B2)</f><v>3.5</v></c><c r="B3"><f t="shared" ref="B3:D4" si="0">A3*2+$A$2+A$2+$A2+SUM(A1:A2)+"A1"</f><v>1</v></c><c r="C3"><f t="shared" si="0"/><v>2</v></c><c r="D3"><f t="shared" si="0"/></c></row>` +37+ `<row r="4"><c r="B4"><f t="shared" si="0"/><v>4</v></c><c r="C4"><f t="shared" si="7"/><v>9</v></c></row>` +38+ `<row r="5"><c r="A5" s="1"><v>10</v></c><c r="E5" s="5" t="s"><v>3</v></c><c r="F5" s="4"/></row>` +39+ `<row r="6"><c r="A6" s="7"/><c r="B6" t="s"><v> 4 </v></c><c r="C6" s="6"><v>12</v></c></row>` +40+ `<row r="7"><c r="A7"><f>CUBEVALUE(1)</f><v>7</v></c><c r="B7" t="str"><f>_xlfn.CONCAT(A1,"a")</f><v>Regiona</v></c><c r="C7" t="b"><f>CUBESET(1)</f><v>0</v></c><c r="D7" t="e"><f>CUBEMEMBER(1)</f><v>#REF!</v></c><c r="E7" t="s"><f>CUBEKPI(1)</f><v>0</v></c></row>` +41+ `<row r="8"><c r="A8" t="n"><v>1E+300</v></c><c r="B8"><v> 12</v></c><c r="C8"><v>abc</v></c><c r="D8" t="s"><v>99</v></c><c r="E8"><v>5</v></c><c r="F8" t="s"><v>5</v></c></row>` +42+ `<row r="9"><c r="B9" t="inlineStr"><is><r><t>in</t></r><r><t>_x0041_</t></r></is></c><c r="C9" t="b"><v>TRUE</v></c><c r="D9" s="8"><v>30</v></c><c r="E9" t="x"><v>8</v></c></row>` +43+ `</sheetData><mergeCells count="1"><mergeCell ref="E3:F3"/></mergeCells></worksheet>`44+45+ kitchenHidden = `<?xml version="1.0" encoding="UTF-8"?><worksheet ` + mainNS + `><sheetData><row><c><v>1</v></c></row><row><c/><c t="s"><v>0</v></c></row></sheetData></worksheet>`46+47+ kitchenRefs = `<?xml version="1.0" encoding="UTF-8"?><worksheet ` + mainNS + `><dimension ref="A1"/><sheetFormatPr baseColWidth="12"/><sheetData><row r="1"><c r="A1"><v>42</v></c><c r="B1"><f>Data!A5*2+SUM(Total)</f><v>84</v></c><c r="C1"><f>SUM(Hidden!A1:A2)</f></c></row></sheetData></worksheet>`48+)49+50+// kitchenParts is a workbook of three sheets using most of what the51+// reader reads.52+func kitchenParts() map[string]string {53+ return map[string]string{54+ "[Content_Types].xml": contentTypes,55+ "_rels/.rels": rootRels,56+ "xl/workbook.xml": kitchenBook,57+ "xl/_rels/workbook.xml.rels": bookRels,58+ "xl/sharedStrings.xml": kitchenStrings,59+ "xl/styles.xml": kitchenStyles,60+ "xl/worksheets/sheet1.xml": kitchenData,61+ "xl/worksheets/sheet2.xml": kitchenHidden,62+ "xl/worksheets/sheet3.xml": kitchenRefs,63+ }64+}65+66+// oneSheetParts is a workbook of one sheet with the given sheetData67+// content, and the workbook's properties.68+func oneSheetParts(workbookPr, sheetData string) map[string]string {69+ p := kitchenParts()70+ p["xl/workbook.xml"] = `<workbook ` + mainNS + `>` + workbookPr + `<sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>`71+ p["xl/worksheets/sheet1.xml"] = `<worksheet ` + mainNS + `><sheetData>` + sheetData + `</sheetData></worksheet>`72+ delete(p, "xl/worksheets/sheet2.xml")73+ delete(p, "xl/worksheets/sheet3.xml")74+ return p75+}76+77+// zipParts packs parts into an XLSX file's bytes, in name order.78+func zipParts(t testing.TB, parts map[string]string) []byte {79+ t.Helper()80+ var buf bytes.Buffer81+ zw := zip.NewWriter(&buf)82+ for _, name := range slices.Sorted(maps.Keys(parts)) {83+ w, err := zw.Create(name)84+ if err != nil {85+ t.Fatal(err)86+ }87+ if _, err := w.Write([]byte(parts[name])); err != nil {88+ t.Fatal(err)89+ }90+ }91+ if err := zw.Close(); err != nil {92+ t.Fatal(err)93+ }94+ return buf.Bytes()95+}96+97+// writeParts writes parts as an XLSX file in dir and returns its path.98+func writeParts(t testing.TB, dir, name string, parts map[string]string) string {99+ t.Helper()100+ path := filepath.Join(dir, name)101+ if err := os.WriteFile(path, zipParts(t, parts), 0o644); err != nil {102+ t.Fatal(err)103+ }104+ return path105+}
A internal/fileio/xlsxpkg.go +294 -0
@@ -0,0 +1,294 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "encoding/xml"6+ "errors"7+ "fmt"8+ "io"9+ "io/fs"10+ "path"11+ "strings"12+)13+14+// An XLSX file is a zip of XML parts (SpreadsheetML in an Open Packaging15+// Conventions package). 012 reads it with archive/zip and encoding/xml,16+// streaming each part a token at a time, so a worksheet is never held in17+// memory. The file is untrusted: xlsxLimits bound what it can make the18+// reader do.19+20+// xlsxLimits bound the work a file can cause. encoding/xml expands no21+// external or custom entities, so entity bombs can't happen; these cap22+// zip bombs, deep nesting and huge single tokens.23+type xlsxLimits struct {24+ entries int // files in the zip25+ part int64 // uncompressed bytes of one part26+ total int64 // uncompressed bytes of all parts read27+ ratio int64 // uncompressed / compressed, past slack bytes28+ slack int6429+ depth int // XML nesting30+ token int64 // bytes of one XML token (a tag or a run of text)31+ strings int // shared strings32+ styles int // cell formats, fonts and number formats, each33+ sheets int34+ shared int64 // bytes of shared formulas expanded35+}36+37+var defaultXLSXLimits = xlsxLimits{38+ entries: 10_000,39+ part: 1 << 30,40+ total: 2 << 30,41+ ratio: 250,42+ slack: 16 << 20,43+ depth: 256,44+ token: 32 << 20,45+ strings: 1 << 24,46+ styles: 1 << 16,47+ sheets: 4096,48+ shared: 256 << 20,49+}50+51+// errXLSXLimit is wrapped by every error for a file past a limit.52+var errXLSXLimit = errors.New("past the reader's limits")53+54+// xlsxPackage is an open XLSX zip: its parts by lower-case name (part55+// names are case-insensitive) and what has been read of them so far.56+type xlsxPackage struct {57+ parts map[string]*zip.File58+ lim xlsxLimits59+ read int64 // uncompressed bytes read, all parts60+}61+62+func openXLSXPackage(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxPackage, error) {63+ zr, err := zip.NewReader(r, size)64+ if err != nil && !errors.Is(err, zip.ErrInsecurePath) {65+ return nil, fmt.Errorf("not an Excel workbook, or an encrypted one (%w)", err)66+ }67+ if len(zr.File) > lim.entries {68+ return nil, fmt.Errorf("the workbook holds %d files, more than %d: %w", len(zr.File), lim.entries, errXLSXLimit)69+ }70+ p := &xlsxPackage{parts: make(map[string]*zip.File, len(zr.File)), lim: lim}71+ for _, f := range zr.File {72+ name := f.Name73+ if strings.HasSuffix(name, "/") {74+ continue // a directory75+ }76+ if !fs.ValidPath(name) || strings.ContainsRune(name, '\\') {77+ return nil, fmt.Errorf("the workbook holds a file with an unsafe name, %q", name)78+ }79+ key := strings.ToLower(name)80+ if _, dup := p.parts[key]; dup {81+ return nil, fmt.Errorf("the workbook holds %s twice", name)82+ }83+ p.parts[key] = f84+ }85+ return p, nil86+}87+88+// has reports whether the package holds a part.89+func (p *xlsxPackage) has(name string) bool {90+ _, ok := p.parts[strings.ToLower(name)]91+ return ok92+}93+94+// open starts streaming a part's XML, or returns nil, nil when there is95+// no such part.96+func (p *xlsxPackage) open(name string) (*xmlStream, error) {97+ f, ok := p.parts[strings.ToLower(name)]98+ if !ok {99+ return nil, nil100+ }101+ rc, err := f.Open()102+ if err != nil {103+ return nil, fmt.Errorf("%s: %w", name, err)104+ }105+ g := &guardReader{r: rc, c: rc, p: p, name: name, comp: int64(f.CompressedSize64)}106+ d := xml.NewDecoder(g)107+ return &xmlStream{d: d, g: g, name: name, maxDepth: p.lim.depth}, nil108+}109+110+// guardReader counts a part's uncompressed bytes as they are read and111+// fails past the limits, so a zip bomb stops early whatever its headers112+// claim.113+type guardReader struct {114+ r io.Reader115+ c io.Closer116+ p *xlsxPackage117+ name string118+ comp int64 // compressed size119+ n int64 // uncompressed bytes read120+ tok int64 // n when the current XML token began121+}122+123+func (g *guardReader) Read(b []byte) (int, error) {124+ k, err := g.r.Read(b)125+ g.n += int64(k)126+ g.p.read += int64(k)127+ lim := g.p.lim128+ switch {129+ case g.n > lim.part:130+ return k, fmt.Errorf("%s unpacks to more than %s: %w", g.name, mb(lim.part), errXLSXLimit)131+ case g.p.read > lim.total:132+ return k, fmt.Errorf("the workbook unpacks to more than %s: %w", mb(lim.total), errXLSXLimit)133+ case g.n > lim.slack && g.n/max(g.comp, 1) > lim.ratio:134+ return k, fmt.Errorf("%s is compressed over %d to 1, like a zip bomb: %w", g.name, lim.ratio, errXLSXLimit)135+ case g.n-g.tok > lim.token:136+ return k, fmt.Errorf("%s has a tag or text over %s: %w", g.name, mb(lim.token), errXLSXLimit)137+ }138+ return k, err139+}140+141+func mb(n int64) string { return fmt.Sprintf("%d MB", n>>20) }142+143+// xmlStream is a part's XML as tokens, checking nesting depth and the144+// size of each token.145+type xmlStream struct {146+ d *xml.Decoder147+ g *guardReader148+ name string149+ depth int150+ maxDepth int151+}152+153+// next returns the next token, or io.EOF at the end of the part. Char154+// data is only valid until the following call.155+func (x *xmlStream) next() (xml.Token, error) {156+ x.g.tok = x.g.n157+ t, err := x.d.Token()158+ if err != nil {159+ if err == io.EOF {160+ return nil, err161+ }162+ return nil, fmt.Errorf("%s: %w", x.name, err)163+ }164+ switch t.(type) {165+ case xml.StartElement:166+ x.depth++167+ if x.depth > x.maxDepth {168+ return nil, fmt.Errorf("%s nests elements more than %d deep: %w", x.name, x.maxDepth, errXLSXLimit)169+ }170+ case xml.EndElement:171+ x.depth--172+ }173+ return t, nil174+}175+176+// skip passes over the rest of the element just started.177+func (x *xmlStream) skip() error {178+ for depth := x.depth; ; {179+ t, err := x.next()180+ if err != nil {181+ return eofAsUnexpected(err)182+ }183+ if _, ok := t.(xml.EndElement); ok && x.depth < depth {184+ return nil185+ }186+ }187+}188+189+// text returns the text of the element just started, up to its end.190+func (x *xmlStream) text(buf []byte) ([]byte, error) {191+ for depth := x.depth; ; {192+ t, err := x.next()193+ if err != nil {194+ return buf, eofAsUnexpected(err)195+ }196+ switch t := t.(type) {197+ case xml.CharData:198+ buf = append(buf, t...)199+ case xml.EndElement:200+ if x.depth < depth {201+ return buf, nil202+ }203+ }204+ }205+}206+207+func (x *xmlStream) close() error { return x.g.c.Close() }208+209+func eofAsUnexpected(err error) error {210+ if err == io.EOF {211+ return io.ErrUnexpectedEOF212+ }213+ return err214+}215+216+// attr returns the value of the attribute with local name name.217+func attr(se xml.StartElement, name string) (string, bool) {218+ for _, a := range se.Attr {219+ if a.Name.Local == name {220+ return a.Value, true221+ }222+ }223+ return "", false224+}225+226+// relAttr returns the r:id of an element: the id attribute in the227+// relationships namespace (transitional or strict).228+func relAttr(se xml.StartElement) string {229+ for _, a := range se.Attr {230+ if a.Name.Local == "id" && strings.HasSuffix(a.Name.Space, "/relationships") {231+ return a.Value232+ }233+ }234+ return ""235+}236+237+// xlsxRel is a relationship from a part to another.238+type xlsxRel struct {239+ typ string // the last element of the type URI, e.g. "worksheet"240+ target string // the part's name, resolved241+}242+243+// rels reads the relationships of part (from its _rels part), resolving244+// targets to part names. Targets outside the package are left out.245+func (p *xlsxPackage) rels(part string) (map[string]xlsxRel, error) {246+ dir, base := path.Split(part)247+ x, err := p.open(dir + "_rels/" + base + ".rels")248+ if x == nil || err != nil {249+ return nil, err250+ }251+ defer x.close()252+ out := map[string]xlsxRel{}253+ for {254+ t, err := x.next()255+ if err == io.EOF {256+ return out, nil257+ }258+ if err != nil {259+ return nil, err260+ }261+ se, ok := t.(xml.StartElement)262+ if !ok || se.Name.Local != "Relationship" {263+ continue264+ }265+ if mode, _ := attr(se, "TargetMode"); strings.EqualFold(mode, "External") {266+ continue267+ }268+ id, _ := attr(se, "Id")269+ typ, _ := attr(se, "Type")270+ target, _ := attr(se, "Target")271+ if name, ok := resolvePart(dir, target); ok && len(out) < p.lim.entries {272+ out[id] = xlsxRel{typ: typ[strings.LastIndexByte(typ, '/')+1:], target: name}273+ }274+ }275+}276+277+// resolvePart resolves a relationship's target against the directory of278+// its source part: /xl/x.xml is from the root, x.xml from dir. A target279+// that climbs out of the package is refused.280+func resolvePart(dir, target string) (string, bool) {281+ if target == "" {282+ return "", false283+ }284+ if strings.HasPrefix(target, "/") {285+ target = target[1:]286+ } else {287+ target = dir + target288+ }289+ name := path.Clean(target)290+ if !fs.ValidPath(name) || name == "." {291+ return "", false292+ }293+ return name, true294+}
A internal/fileio/xlsxread_test.go +273 -0
@@ -0,0 +1,273 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// readParts imports a workbook of parts with the given limits.14+func readParts(t testing.TB, parts map[string]string, lim xlsxLimits) (*Result, error) {15+ t.Helper()16+ data := zipParts(t, parts)17+ bk, err := openXLSX(bytes.NewReader(data), int64(len(data)), lim)18+ if err != nil {19+ return nil, err20+ }21+ return bk.importBook(context.Background(), Options{})22+}23+24+func TestXLSXReader(t *testing.T) {25+ res, err := readParts(t, kitchenParts(), defaultXLSXLimits)26+ if err != nil {27+ t.Fatal(err)28+ }29+ book := res.Sheet.Book()30+ if book.Len() != 3 || res.Sheet.Name() != "Refs" || res.Rows != 12 {31+ t.Fatalf("%d sheets, showing %s, %d rows", book.Len(), res.Sheet.Name(), res.Rows)32+ }33+ data := book.Sheet(0)34+ for cell, want := range map[string]string{35+ "A1": "Region", "B1": "Rich text", "C1": "inline", "D1": "TRUE", "E1": "#N/A", "F1": `=A1&"x"`,36+ "A3": "=SUM(A2:B2)",37+ "B3": `=A3*2+$A$2+A$2+$A2+SUM(A1:A2)+"A1"`,38+ "C3": `=B3*2+$A$2+B$2+$A2+SUM(B1:B2)+"A1"`,39+ "D3": `=C3*2+$A$2+C$2+$A2+SUM(C1:C2)+"A1"`,40+ "B4": `=A4*2+$A$2+A$2+$A3+SUM(A2:A3)+"A1"`,41+ "C4": "9", "E5": "'=not a formula", "B6": "'123", "C6": "12",42+ "A7": "7", "B7": `=CONCAT(A1,"a")`, "C7": "FALSE", "D7": "#REF!", "E7": "Region",43+ "A8": "1E+300", "B8": "' 12", "C8": "abc", "D8": "", "E8": "5",44+ "B9": "inA", "C9": "TRUE", "E9": "8",45+ } {46+ if g := input(data, addr(t, cell)); g != want {47+ t.Errorf("Data!%s input %q, want %q", cell, g, want)48+ }49+ }50+ if g := input(data, addr(t, "A1")); g != "Region" {51+ t.Errorf("A1 %q", g)52+ }53+ for _, cell := range []string{"C2", "D8", "A6"} {54+ if data.Cell(addr(t, cell)) == nil {55+ t.Errorf("formatted blank %s left out", cell)56+ }57+ }58+ for _, cell := range []string{"F5", "F8"} {59+ if c := data.Cell(addr(t, cell)); c != nil {60+ t.Errorf("%s kept: %+v", cell, c)61+ }62+ }63+ checkKitchenStyles(t, data)64+ for col, want := range map[int]int{0: 21, 1: 11, 2: 6, 3: 6, 4: 31, 5: 11, 200: 11} {65+ if w := data.ColWidth(col); w != want {66+ t.Errorf("Data column %d width %d, want %d", col, w, want)67+ }68+ }69+ if w := book.Sheet(2).ColWidth(7); w != 13 {70+ t.Errorf("Refs width %d", w)71+ }72+ if !book.Sheet(1).Hidden() || res.Sheet.Hidden() {73+ t.Errorf("Hidden hidden %v, Refs hidden %v", book.Sheet(1).Hidden(), res.Sheet.Hidden())74+ }75+ if g := input(book.Sheet(1), addr(t, "B2")); g != "Region" {76+ t.Errorf("Hidden!B2 %q", g)77+ }78+ if g := shown(book.Sheet(2), addr(t, "B1")); g != "24.50" {79+ t.Errorf("Refs!B1 shows %q", g) // Data!A5*2 + SUM(Total), A2 a date: 2.580+ }81+ notes := strings.Join(res.Notes, "; ")82+ for _, want := range []string{83+ "2 named ranges left out, e.g. Local",84+ "4 formulas kept as values, e.g. A7 =CUBEVALUE(1)",85+ } {86+ if !strings.Contains(notes, want) {87+ t.Errorf("notes %q, want %q", notes, want)88+ }89+ }90+}91+92+func checkKitchenStyles(t *testing.T, data *sheet.Sheet) {93+ t.Helper()94+ cell := func(s string) sheet.Cell {95+ if c := data.Cell(addr(t, s)); c != nil {96+ return *c97+ }98+ return sheet.Cell{}99+ }100+ if f := cell("A2").Format; f.Kind != sheet.FmtDate { // the row's style101+ t.Errorf("A2 format %+v", f)102+ }103+ if f := cell("B2").Format; f != (sheet.Format{Kind: sheet.FmtCustom, Pattern: "0.000"}) {104+ t.Errorf("B2 format %+v", f)105+ }106+ if c := cell("E2"); c.Format.Kind != sheet.FmtDate || c.Style.Bold || c.Style.Align != sheet.AlignLeft {107+ t.Errorf("E2 %+v %+v (applyFont=0 isn't bold)", c.Format, c.Style)108+ }109+ if c := cell("A5"); !c.Style.Bold || c.Format != (sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}) {110+ t.Errorf("A5 %+v %+v", c.Format, c.Style)111+ }112+ if st := cell("E5").Style; !st.Strikethrough || st.Underline || st.Align != sheet.AlignAuto {113+ t.Errorf("E5 style %+v", st)114+ }115+ if st := cell("A6").Style; st.Bold || st.Align != sheet.AlignCenter {116+ t.Errorf("A6 style %+v", st)117+ }118+ if f := cell("C6").Format; !f.IsZero() {119+ t.Errorf("C6 format %+v (id 5 is no built-in to excelize)", f)120+ }121+ if c := cell("D9"); c.Format.Kind != sheet.FmtDate || shown(data, addr(t, "D9")) != "1/30/1900" {122+ t.Errorf("D9 %+v shows %s", c.Format, shown(data, addr(t, "D9")))123+ }124+}125+126+func TestShiftFormula(t *testing.T) {127+ for _, tc := range []struct {128+ in string129+ dCol, dRow int130+ want string131+ }{132+ {"A1+B2", 1, 1, "B2+C3"},133+ {"$A$1+$A1+A$1", 2, 3, "$A$1+$A4+C$1"},134+ {"SUM(A1:B2)*LOG10(A1)", 0, 1, "SUM(A2:B3)*LOG10(A2)"},135+ {"Sheet2!A1+'My sheet'!B$2", 1, 1, "Sheet2!B2+'My sheet'!C$2"},136+ {"SUM(A:A,1:1,$B:$B,$2:$2)", 1, 1, "SUM(B:B,2:2,$B:$B,$2:$2)"},137+ {`"A1"&A1&Total&TRUE&1E+5&0.5`, 1, 0, `"A1"&B1&Total&TRUE&1E+5&0.5`},138+ {"A1-1", -1, 0, "#REF!-1"},139+ {"XFD1+A1048576", 1, 1, "#REF!+#REF!"},140+ {"Table1[[#This Row],[A1]]+A1", 1, 0, "Table1[[#This Row],[A1]]+B1"},141+ {"_xlfn.XLOOKUP(A1,B:B,C:C)", 0, 5, "_xlfn.XLOOKUP(A6,B:B,C:C)"},142+ {"#REF!+A1", 0, 1, "#REF!+A2"},143+ {"A1 B1", 25, 0, "Z1 AA1"},144+ {"ZZ1", 1, 0, "AAA1"},145+ } {146+ if got := shiftFormula(tc.in, tc.dCol, tc.dRow); got != tc.want {147+ t.Errorf("shiftFormula(%q, %d, %d) = %q, want %q", tc.in, tc.dCol, tc.dRow, got, tc.want)148+ }149+ }150+}151+152+// Things excelize reads wrongly or not at all: shared formulas referring153+// to another sheet move, the 1904 date system, t="d" ISO dates, and154+// columns past 1-2-3's IV.155+func TestXLSXBeyondExcelize(t *testing.T) {156+ parts := oneSheetParts(`<workbookPr date1904="1"/>`,157+ `<row r="1"><c r="A1"><v>1</v></c><c r="B1"><f t="shared" ref="B1:B2" si="0">Other!A1+A1</f></c><c r="C1" s="8"><v>0</v></c><c r="D1" t="d"><v>2026-09-26T14:30:00</v></c><c r="E1" t="d"><v>2026-09-26</v></c><c r="F1" t="d"><v>soon</v></c><c r="IZ1"><v>1</v></c></row>`+158+ `<row r="2"><c r="B2"><f t="shared" si="0"/></c></row>`)159+ res, err := readParts(t, parts, defaultXLSXLimits)160+ if err != nil {161+ t.Fatal(err)162+ }163+ s := res.Sheet164+ if g := input(s, addr(t, "B2")); g != "=Other!A2+A2" {165+ t.Errorf("B2 %q", g)166+ }167+ for cell, want := range map[string]string{"C1": "1/1/1904", "D1": "9/26/2026 14:30:00", "E1": "9/26/2026", "F1": "soon"} {168+ if g := shown(s, addr(t, cell)); g != want {169+ t.Errorf("%s shows %q, want %q", cell, g, want)170+ }171+ }172+ if g := input(s, addr(t, "IZ1")); g != "1" || len(res.Notes) != 0 { // past 1-2-3's IV, inside the grid173+ t.Errorf("IZ1 %q, notes %q", g, res.Notes)174+ }175+}176+177+func TestXLSXLimits(t *testing.T) {178+ small := defaultXLSXLimits179+ small.part, small.total, small.slack, small.token, small.ratio, small.entries, small.shared = 1<<20, 2<<20, 64<<10, 64<<10, 100, 50, 1<<20180+ bomb := oneSheetParts("", strings.Repeat(`<row><c><v>0</v></c></row>`, 100_000))181+ var rows, text strings.Builder182+ for i := range 60_000 {183+ rows.WriteString(`<row><c><v>` + itoa(i*7919%100_003) + `</v></c></row>`)184+ text.WriteString(itoa(i * 7919 % 100_003))185+ }186+ big := oneSheetParts("", rows.String())187+ long := oneSheetParts("", `<row><c t="inlineStr"><is><t>`+text.String()+`</t></is></c></row>`)188+ deep := oneSheetParts("", `<row><c><v>1</v><x>`+strings.Repeat("<y>", 300)+strings.Repeat("</y>", 300)+`</x></c></row>`)189+ many := kitchenParts()190+ for i := range small.entries {191+ many["xl/media/image"+itoa(i)+".png"] = ""192+ }193+ for name, tc := range map[string]struct {194+ parts map[string]string195+ want string196+ ratio int64 // instead of small.ratio197+ }{198+ "zip bomb": {bomb, "compressed over 100 to 1, like a zip bomb", 0},199+ "big part": {big, "unpacks to more than 1 MB", 0},200+ "long text": {long, "a tag or text over", 0},201+ "deep": {deep, "nests elements more than 256 deep", 0},202+ "many files": {many, "files, more than 50", 0},203+ "row past Excel": {oneSheetParts("", `<row r="1048577"><c><v>1</v></c></row>`), "past Excel's last row", 0},204+ "col past Excel": {oneSheetParts("", `<row><c r="XFE1"><v>1</v></c></row>`), `"XFE1" isn't a cell reference`, 0},205+ "bad reference": {oneSheetParts("", `<row><c r="A0"><v>1</v></c></row>`), `"A0" isn't a cell reference`, 0},206+ "too many cells": {oneSheetParts("", `<row>`+strings.Repeat(`<c r="A1"/>`, excelCols+1)+`</row>`), "more than 16,384 cells", 1 << 30},207+ "unsafe name": {mergeParts(kitchenParts(), "../evil.xml", "x"), "unsafe name", 0},208+ "absolute name": {mergeParts(kitchenParts(), "/etc/passwd", "x"), "unsafe name", 0},209+ "duplicate": {mergeParts(kitchenParts(), "XL/Workbook.xml", "x"), "twice", 0},210+ "entity": {oneSheetParts("", `<row><c t="inlineStr"><is><t>&bomb;</t></is></c></row>`), "invalid character entity &bomb;", 0},211+ "shared formulas": {oneSheetParts("", `<row r="1"><c r="A1"><f t="shared" ref="A1:A9" si="0">`+strings.Repeat("B1+", 1000)+`1</f></c></row>`+212+ strings.Repeat(`<row><c r="A2"><f t="shared" si="0"/></c></row>`, 400)), "shared formulas expand to more than 1 MB", 0},213+ "not xml": {oneSheetParts("", `<row><c>`), "closed by </sheetData>", 0},214+ } {215+ t.Run(name, func(t *testing.T) {216+ lim := small217+ if tc.ratio > 0 {218+ lim.ratio = tc.ratio219+ }220+ _, err := readParts(t, tc.parts, lim)221+ if err == nil || !strings.Contains(err.Error(), tc.want) {222+ t.Fatalf("error %v, want %q", err, tc.want)223+ }224+ })225+ }226+ // The limits error wraps errXLSXLimit.227+ if _, err := readParts(t, bomb, small); !errors.Is(err, errXLSXLimit) {228+ t.Errorf("%v isn't a limits error", err)229+ }230+ // A DTD declaring entities is refused, not expanded.231+ dtd := oneSheetParts("", "")232+ dtd["xl/sharedStrings.xml"] = `<?xml version="1.0"?><!DOCTYPE sst [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;">]><sst ` + mainNS + `><si><t>&b;</t></si></sst>`233+ if _, err := readParts(t, dtd, small); err == nil || !strings.Contains(err.Error(), "invalid character entity &b;") {234+ t.Errorf("entity: %v", err)235+ }236+}237+238+func mergeParts(p map[string]string, name, content string) map[string]string {239+ p[name] = content240+ return p241+}242+243+// FuzzReadXLSX feeds the reader arbitrary files: it must fail cleanly,244+// never panic or run past its limits.245+func FuzzReadXLSX(f *testing.F) {246+ f.Add(zipParts(f, kitchenParts()))247+ f.Add(zipParts(f, oneSheetParts(`<workbookPr date1904="1"/>`, `<row><c r="B2" t="d"><v>2026-01-01</v></c></row>`)))248+ f.Fuzz(func(t *testing.T, data []byte) {249+ lim := defaultXLSXLimits250+ lim.part, lim.total = 4<<20, 8<<20251+ bk, err := openXLSX(bytes.NewReader(data), int64(len(data)), lim)252+ if err != nil {253+ return254+ }255+ bk.importBook(context.Background(), Options{})256+ })257+}258+259+// FuzzReadXLSXParts fuzzes the XML of a worksheet, the shared strings260+// and the styles, zipped into a workbook, where the zip's checksums261+// would stop most mutations of a whole file.262+func FuzzReadXLSXParts(f *testing.F) {263+ k := kitchenParts()264+ f.Add(k["xl/worksheets/sheet1.xml"], k["xl/sharedStrings.xml"], k["xl/styles.xml"])265+ f.Add(`<worksheet><sheetData><row r="3"><c r="C3"><f t="shared" ref="C3:C9" si="0">A1:B$2+'x y'!A1</f></c></row><row><c r="C4"><f t="shared" si="0"/></c></row></sheetData></worksheet>`, "", "")266+ f.Fuzz(func(t *testing.T, ws, sst, styles string) {267+ p := kitchenParts()268+ p["xl/worksheets/sheet1.xml"], p["xl/sharedStrings.xml"], p["xl/styles.xml"] = ws, sst, styles269+ lim := defaultXLSXLimits270+ lim.part, lim.total = 4<<20, 8<<20271+ readParts(t, p, lim)272+ })273+}
A internal/fileio/xlsxsheet.go +422 -0
@@ -0,0 +1,422 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Excel's own limits on a worksheet. A reference past them is an error,14+// as it is in Excel.15+const (16+ excelRows = 1 << 2017+ excelCols = 1 << 1418+)19+20+// xlsxCell is a cell of a worksheet row, as the reader passes it on.21+type xlsxCell struct {22+ col int // from 123+ style int // the cell format index (s), 0 when not given24+ typ string // the type (t): s, str, inlineStr, b, e, n, d or ""25+ value string // the value, a shared string looked up26+ f cellFormula // its <f>, see xlsxSheetReader.formula27+ hasF bool // the cell has a formula element, even an empty one28+}29+30+// kept reports whether excelize's row reader would return the cell: it31+// has a value or a formula element. The others are formatted blanks.32+func (c *xlsxCell) kept() bool { return c.value != "" || c.hasF }33+34+// xlsxCol is a <col> element: widths and styles for columns min to max.35+type xlsxCol struct {36+ min, max int37+ width float6438+ hasWidth bool39+ style int40+}41+42+// xlsxSheetReader streams a worksheet's rows. Opening it reads what43+// comes before the cells: column widths and styles, the default width.44+type xlsxSheetReader struct {45+ bk *xlsxBook46+ x *xmlStream47+ cols []xlsxCol48+ // sheetFormatPr's defaultColWidth and baseColWidth.49+ defaultWidth float6450+ baseWidth int51+52+ colStyles []int // by column, see colStyle53+54+ inData bool // inside <sheetData>55+ row xlsxRowData56+ shared map[int]sharedFormula57+ buf []byte58+}59+60+// xlsxRowData is the row just read.61+type xlsxRowData struct {62+ num int // from 163+ style int // the row's cell format index, for cells not written64+ cells []xlsxCell65+}66+67+// sharedFormula is the master cell of a shared formula.68+type sharedFormula struct {69+ text string70+ col, row int71+}72+73+// openSheet opens sheet i. A sheet with no worksheet part (a chart74+// sheet) reads as empty.75+func (bk *xlsxBook) openSheet(i int) (*xlsxSheetReader, error) {76+ info := bk.sheets[i]77+ if info.part == "" {78+ return nil, fmt.Errorf("sheet %s: its part is missing", info.name)79+ }80+ x, err := bk.pkg.open(info.part)81+ if err != nil {82+ return nil, err83+ }84+ r := &xlsxSheetReader{bk: bk, x: x, shared: map[int]sharedFormula{}}85+ if info.kind != "worksheet" {86+ return r, nil87+ }88+ return r, r.readHead()89+}90+91+func (r *xlsxSheetReader) close() error { return r.x.close() }92+93+// readHead reads up to the start of <sheetData>.94+func (r *xlsxSheetReader) readHead() error {95+ for {96+ t, err := r.x.next()97+ if err == io.EOF {98+ return nil99+ }100+ if err != nil {101+ return err102+ }103+ se, ok := t.(xml.StartElement)104+ if !ok || r.x.depth != 2 && se.Name.Local != "col" {105+ continue106+ }107+ switch se.Name.Local {108+ case "sheetData":109+ r.inData = true110+ return nil111+ case "sheetFormatPr":112+ r.defaultWidth, _ = strconv.ParseFloat(attrOr(se, "defaultColWidth", "0"), 64)113+ r.baseWidth = intAttr(se, "baseColWidth", 0)114+ case "col":115+ if r.x.depth == 3 && len(r.cols) < excelCols {116+ w, err := strconv.ParseFloat(attrOr(se, "width", ""), 64)117+ r.cols = append(r.cols, xlsxCol{118+ min: intAttr(se, "min", 0), max: intAttr(se, "max", 0),119+ width: w, hasWidth: err == nil, style: intAttr(se, "style", 0),120+ })121+ }122+ }123+ }124+}125+126+// colStyle is the cell format of column col (from 1): the first <col>127+// covering it with a style. Columns up to sheet.MaxCols, the ones asked128+// for once per cell, are looked up once.129+func (r *xlsxSheetReader) colStyle(col int) int {130+ if r.colStyles == nil {131+ r.colStyles = make([]int, sheet.MaxCols)132+ for _, c := range r.cols {133+ for i := max(c.min, 1); i <= min(c.max, sheet.MaxCols); i++ {134+ if r.colStyles[i-1] == 0 {135+ r.colStyles[i-1] = c.style136+ }137+ }138+ }139+ }140+ if col >= 1 && col <= len(r.colStyles) {141+ return r.colStyles[col-1]142+ }143+ for _, c := range r.cols {144+ if c.min <= col && col <= c.max && c.style != 0 {145+ return c.style146+ }147+ }148+ return 0149+}150+151+// lineStyle is the style of column col (from 1) as a whole: that of the152+// last <col> covering it, as for its width (and as excelize reads it).153+func (r *xlsxSheetReader) lineStyle(col int) int {154+ style := 0155+ for _, c := range r.cols {156+ if c.min <= col && col <= c.max {157+ style = c.style158+ }159+ }160+ return style161+}162+163+// colWidth is column col's width in Excel's characters: the last <col>164+// covering it with a width, else the sheet's default.165+func (r *xlsxSheetReader) colWidth(col int) float64 {166+ w := 0.0167+ for _, c := range r.cols {168+ if c.min <= col && col <= c.max && c.hasWidth {169+ w = c.width170+ }171+ }172+ switch {173+ case w != 0:174+ return w175+ case r.defaultWidth > 0:176+ return r.defaultWidth177+ case r.baseWidth > 0:178+ return float64(r.baseWidth)179+ }180+ return excelDefaultWidth181+}182+183+// next reads the next row into r.row, returning false after the last.184+func (r *xlsxSheetReader) next() (bool, error) {185+ for r.inData {186+ t, err := r.x.next()187+ if err != nil {188+ return false, eofAsUnexpected(err)189+ }190+ switch t := t.(type) {191+ case xml.StartElement:192+ if t.Name.Local == "row" && r.x.depth == 3 {193+ return true, r.readRow(t)194+ }195+ case xml.EndElement:196+ if r.x.depth < 2 {197+ r.inData = false198+ }199+ }200+ }201+ return false, nil202+}203+204+// readRow reads a <row>. A row without a number follows the one before.205+func (r *xlsxSheetReader) readRow(se xml.StartElement) error {206+ num := intAttr(se, "r", 0)207+ if num <= 0 {208+ num = r.row.num + 1209+ }210+ if num > excelRows {211+ return fmt.Errorf("row %s is past Excel's last row, %s", thousands(num), thousands(excelRows))212+ }213+ r.row.num, r.row.style = num, intAttr(se, "s", 0)214+ r.row.cells = r.row.cells[:0]215+ col := 0216+ for {217+ t, err := r.x.next()218+ if err != nil {219+ return eofAsUnexpected(err)220+ }221+ switch t := t.(type) {222+ case xml.StartElement:223+ if t.Name.Local != "c" || r.x.depth != 4 {224+ if err := r.x.skip(); err != nil {225+ return err226+ }227+ continue228+ }229+ if len(r.row.cells) >= excelCols {230+ return fmt.Errorf("row %s has more than %s cells", thousands(num), thousands(excelCols))231+ }232+ if col, err = r.readCell(t, col); err != nil {233+ return err234+ }235+ case xml.EndElement:236+ return nil237+ }238+ }239+}240+241+// readCell reads a <c> following column prev and appends it to the row,242+// returning its column.243+func (r *xlsxSheetReader) readCell(se xml.StartElement, prev int) (int, error) {244+ c := xlsxCell{col: prev + 1, style: intAttr(se, "s", 0)}245+ c.typ, _ = attr(se, "t")246+ if ref, ok := attr(se, "r"); ok && ref != "" {247+ col, _, ok := parseCellRef(ref)248+ if !ok {249+ return 0, fmt.Errorf("row %s: %q isn't a cell reference", thousands(r.row.num), ref)250+ }251+ c.col = col252+ }253+ if c.col > excelCols {254+ return 0, fmt.Errorf("row %s: a cell past Excel's last column, XFD", thousands(r.row.num))255+ }256+ var v, is string257+ var hasIS bool258+ var f cellFormula259+ depth := r.x.depth260+ for {261+ t, err := r.x.next()262+ if err != nil {263+ return 0, eofAsUnexpected(err)264+ }265+ if _, ok := t.(xml.EndElement); ok && r.x.depth < depth {266+ break267+ }268+ se, ok := t.(xml.StartElement)269+ if !ok {270+ continue271+ }272+ switch se.Name.Local {273+ case "v":274+ r.buf, err = r.x.text(r.buf[:0])275+ v = string(r.buf)276+ case "f":277+ c.hasF = true278+ f, err = r.readFormula(se)279+ case "is":280+ r.buf, err = inlineText(r.x, r.buf[:0])281+ is, hasIS = string(unescapeOOXML(r.buf)), true282+ default:283+ err = r.x.skip()284+ }285+ if err != nil {286+ return 0, err287+ }288+ }289+ c.value = r.value(c.typ, v, is, hasIS)290+ if c.hasF {291+ c.f = f292+ if _, ok := r.shared[f.si]; !ok && f.typ == "shared" && f.si >= 0 && f.ref != "" {293+ r.shared[f.si] = sharedFormula{text: f.text, col: c.col, row: r.row.num}294+ }295+ }296+ r.row.cells = append(r.row.cells, c)297+ return c.col, nil298+}299+300+// value is a cell's value: its shared string, its inline string, or the301+// text of its <v>.302+func (r *xlsxSheetReader) value(typ, v, is string, hasIS bool) string {303+ switch typ {304+ case "s":305+ if v == "" {306+ return ""307+ }308+ i, err := strconv.Atoi(strings.TrimSpace(v))309+ if err != nil {310+ return ""311+ }312+ s, _ := r.bk.sst.get(i)313+ return s314+ case "inlineStr":315+ if hasIS {316+ return is317+ }318+ }319+ return v320+}321+322+// cellFormula is an <f> element.323+type cellFormula struct {324+ text, typ, ref string325+ si int // -1 when not given326+}327+328+func (r *xlsxSheetReader) readFormula(se xml.StartElement) (cellFormula, error) {329+ f := cellFormula{si: intAttr(se, "si", -1)}330+ f.typ, _ = attr(se, "t")331+ f.ref, _ = attr(se, "ref")332+ var err error333+ r.buf, err = r.x.text(r.buf[:0])334+ f.text = string(r.buf)335+ return f, err336+}337+338+// formula is the formula of cell c of the current row: its own text,339+// or for a cell sharing a formula, the first cell's with its relative340+// references moved by the distance between the cells ("" when the first341+// is missing). Shared formulas are expanded only on request, and only342+// up to the limit on their text: a small file of shared formulas could343+// otherwise expand to gigabytes.344+func (r *xlsxSheetReader) formula(c *xlsxCell) (string, error) {345+ f := c.f346+ if !c.hasF || f.typ != "shared" || f.si < 0 {347+ return f.text, nil348+ }349+ m, ok := r.shared[f.si]350+ if !ok {351+ return "", nil352+ }353+ out := shiftFormula(m.text, c.col-m.col, r.row.num-m.row)354+ if r.bk.expanded += int64(len(out)); r.bk.expanded > r.bk.pkg.lim.shared {355+ return "", fmt.Errorf("shared formulas expand to more than %s: %w", mb(r.bk.pkg.lim.shared), errXLSXLimit)356+ }357+ return out, nil358+}359+360+// parseCellRef parses an A1 reference (without $) into a column and row361+// from 1, within Excel's limits.362+func parseCellRef(ref string) (col, row int, ok bool) {363+ i := 0364+ for i < len(ref) && i < 4 && isLetter(ref[i]) {365+ col = col*26 + int(upper(ref[i])-'A'+1)366+ i++367+ }368+ if i == 0 || i == len(ref) || col > excelCols {369+ return 0, 0, false370+ }371+ for _, c := range []byte(ref[i:]) {372+ if !isDigitByte(c) {373+ return 0, 0, false374+ }375+ row = row*10 + int(c-'0')376+ if row > excelRows {377+ return 0, 0, false378+ }379+ }380+ return col, row, row >= 1381+}382+383+func isLetter(c byte) bool { return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' }384+385+func upper(c byte) byte {386+ if c >= 'a' && c <= 'z' {387+ return c - 'a' + 'A'388+ }389+ return c390+}391+392+// dimension is the last row of sheet i by its <dimension> element, for393+// the progress bar; 0 when it doesn't say.394+func (bk *xlsxBook) dimension(i int) int {395+ if bk.sheets[i].part == "" {396+ return 0397+ }398+ x, err := bk.pkg.open(bk.sheets[i].part)399+ if x == nil || err != nil {400+ return 0401+ }402+ defer x.close()403+ for {404+ t, err := x.next()405+ if err != nil {406+ return 0407+ }408+ se, ok := t.(xml.StartElement)409+ switch {410+ case !ok:411+ case se.Name.Local == "sheetData":412+ return 0413+ case se.Name.Local == "dimension":414+ ref, _ := attr(se, "ref")415+ if _, to, ok := strings.Cut(ref, ":"); ok {416+ _, row, _ := parseCellRef(strings.ReplaceAll(to, "$", ""))417+ return row418+ }419+ return 0420+ }421+ }422+}
A internal/fileio/xlsxshift.go +178 -0
@@ -0,0 +1,178 @@
1+package fileio2+3+import (4+ "strconv"5+ "strings"6+)7+8+// shiftFormula moves the relative references of an Excel formula by9+// dCol columns and dRow rows, as Excel does to copy a shared formula10+// from its first cell to the others: A1 and A$1 move, $A$1 doesn't, and11+// whole columns (A:B) and rows (1:2) move along their axis. Text,12+// quoted sheet names and function names stay as they are. A reference13+// moved off the sheet becomes #REF!.14+func shiftFormula(f string, dCol, dRow int) string {15+ if dCol == 0 && dRow == 0 {16+ return f17+ }18+ var b strings.Builder19+ for i := 0; i < len(f); {20+ c := f[i]21+ switch {22+ case c == '"':23+ j := stringEnd(f, i)24+ if j < 0 {25+ j = len(f)26+ }27+ b.WriteString(f[i:j])28+ i = j29+ case c == '\'':30+ j := quoteEnd(f, i)31+ b.WriteString(f[i:j])32+ i = j33+ case c == '[':34+ // A structured reference or an external book's index.35+ j := strings.IndexByte(f[i:], ']')36+ if j < 0 {37+ j = len(f) - i - 138+ }39+ b.WriteString(f[i : i+j+1])40+ i += j + 141+ case isRefByte(c):42+ j := i43+ for j < len(f) && isRefByte(f[j]) {44+ j++45+ }46+ if word := f[i:j]; j < len(f) && f[j] == '(' {47+ b.WriteString(word) // a function48+ } else {49+ b.WriteString(shiftRef(word, dCol, dRow))50+ }51+ i = j52+ default:53+ b.WriteByte(c)54+ i++55+ }56+ }57+ return b.String()58+}59+60+func isRefByte(c byte) bool { return isIdentByte(c) || c == ':' || c == '!' }61+62+// refPart is one side of a reference: a cell (A1), a column (A) or a63+// row (1), each part with or without $.64+type refPart struct {65+ prefix string // a sheet name and !, if any66+ col, row int // from 1; 0 when absent67+ absCol, absRow bool68+}69+70+// shiftRef moves a reference such as A1, $A1:B$2, Sheet1!A1 or A:B, and71+// returns anything else (a name, a number) unchanged.72+func shiftRef(word string, dCol, dRow int) string {73+ sides := strings.Split(word, ":")74+ if len(sides) > 2 {75+ return word76+ }77+ var parts [2]refPart78+ for i, s := range sides {79+ p, ok := parseRefPart(s)80+ if !ok {81+ return word82+ }83+ parts[i] = p84+ }85+ first := parts[0]86+ for _, p := range parts[1:len(sides)] {87+ if (p.col > 0) != (first.col > 0) || (p.row > 0) != (first.row > 0) {88+ return word // mixed kinds: not a reference89+ }90+ }91+ if len(sides) == 1 && (first.col == 0 || first.row == 0) {92+ return word // a lone column or row is a name or a number93+ }94+ out := make([]string, len(sides))95+ for i, p := range parts[:len(sides)] {96+ if p.col > 0 && !p.absCol {97+ p.col += dCol98+ }99+ if p.row > 0 && !p.absRow {100+ p.row += dRow101+ }102+ if first.col > 0 && (p.col < 1 || p.col > excelCols) || first.row > 0 && (p.row < 1 || p.row > excelRows) {103+ return first.prefix + "#REF!"104+ }105+ out[i] = p.String()106+ }107+ return strings.Join(out, ":")108+}109+110+// parseRefPart parses [Sheet!][$]COL[$]ROW, [$]COL or [$]ROW.111+func parseRefPart(s string) (refPart, bool) {112+ var p refPart113+ if k := strings.LastIndexByte(s, '!'); k >= 0 {114+ p.prefix, s = s[:k+1], s[k+1:]115+ }116+ i := 0117+ dollar := i < len(s) && s[i] == '$'118+ if dollar {119+ i++120+ }121+ letters := i122+ for i < len(s) && isLetter(s[i]) && i-letters < 3 {123+ p.col = p.col*26 + int(upper(s[i])-'A'+1)124+ i++125+ }126+ if i == letters {127+ p.absRow = dollar // no column: a row, and the $ is the row's128+ } else {129+ p.absCol = dollar130+ if i < len(s) && s[i] == '$' {131+ p.absRow = true132+ i++133+ }134+ }135+ digits := i136+ for i < len(s) && isDigitByte(s[i]) && i-digits < 7 {137+ p.row = p.row*10 + int(s[i]-'0')138+ i++139+ }140+ switch {141+ case i != len(s), i == letters, p.col > excelCols, p.row > excelRows:142+ return p, false143+ case i > digits && p.row == 0, i == digits && p.absRow:144+ return p, false // row 0, or a $ with no row after it145+ }146+ return p, true147+}148+149+func (p refPart) String() string {150+ var b strings.Builder151+ b.WriteString(p.prefix)152+ if p.col > 0 {153+ if p.absCol {154+ b.WriteByte('$')155+ }156+ b.WriteString(excelColName(p.col))157+ }158+ if p.row > 0 {159+ if p.absRow {160+ b.WriteByte('$')161+ }162+ b.WriteString(strconv.Itoa(p.row))163+ }164+ return b.String()165+}166+167+// excelColName is the letters of column col, from 1 (A) to 16384 (XFD).168+func excelColName(col int) string {169+ var b [3]byte170+ i := len(b)171+ for col > 0 && i > 0 {172+ col--173+ i--174+ b[i] = byte('A' + col%26)175+ col /= 26176+ }177+ return string(b[i:])178+}
A internal/fileio/xlsxstyles.go +223 -0
@@ -0,0 +1,223 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// xlsxStyles is what 012 reads of the styles part: custom number14+// formats, fonts, and the cell formats (cellXfs) that cells refer to by15+// index, resolved to 012 formats and styles on first use.16+type xlsxStyles struct {17+ numFmts map[int]string18+ fonts []xlsxFont19+ xfs []xlsxXf20+ cache map[int]xlsxStyle21+}22+23+type xlsxFont struct{ bold, italic, strike, underline bool }24+25+// xlsxXf is a cell format; numFmt and font are -1 when not given.26+type xlsxXf struct {27+ numFmt, font int28+ applyFont, applyAlign bool29+ align sheet.Align30+}31+32+// style is the cell format with index id, as 012 keeps it; an unknown33+// id is the default.34+func (s *xlsxStyles) style(id int) xlsxStyle {35+ if id <= 0 || id >= len(s.xfs) {36+ return xlsxStyle{}37+ }38+ if st, ok := s.cache[id]; ok {39+ return st40+ }41+ xf := s.xfs[id]42+ var out xlsxStyle43+ out.format = s.format(xf.numFmt)44+ if xf.applyFont && xf.font >= 0 && xf.font < len(s.fonts) {45+ f := s.fonts[xf.font]46+ out.style.Bold, out.style.Italic, out.style.Strikethrough, out.style.Underline = f.bold, f.italic, f.strike, f.underline47+ }48+ if xf.applyAlign {49+ out.style.Align = xf.align50+ }51+ if s.cache == nil {52+ s.cache = map[int]xlsxStyle{}53+ }54+ s.cache[id] = out55+ return out56+}57+58+// format maps a number format id to a 012 format. Built-in ids (and the59+// locale-dependent ones) mean their built-in code even when the file60+// defines them again; others are looked up among the custom codes.61+func (s *xlsxStyles) format(id int) sheet.Format {62+ if id < 0 {63+ return sheet.Format{}64+ }65+ if builtinNumFmt(id) {66+ return formatOf(id, "")67+ }68+ return formatOf(0, s.numFmts[id])69+}70+71+// builtinNumFmt reports whether id is one of Excel's built-in number72+// formats: the ones every locale shares, and the ranges each locale73+// defines for itself.74+func builtinNumFmt(id int) bool {75+ switch {76+ case id >= 0 && id <= 4, id >= 9 && id <= 22, id >= 37 && id <= 49:77+ return true78+ case id >= 27 && id <= 36, id >= 50 && id <= 62, id >= 67 && id <= 81:79+ return true80+ }81+ return false82+}83+84+// read streams the styles part.85+func (s *xlsxStyles) read(p *xlsxPackage, part string) error {86+ x, err := p.open(part)87+ if x == nil || err != nil {88+ return err89+ }90+ defer x.close()91+ s.numFmts = map[int]string{}92+ section := "" // numFmts, fonts or cellXfs, at depth 293+ var cur *xlsxXf // the <xf> being read94+ for {95+ t, err := x.next()96+ if err == io.EOF {97+ return nil98+ }99+ if err != nil {100+ return err101+ }102+ switch t := t.(type) {103+ case xml.StartElement:104+ if x.depth == 2 {105+ section = t.Name.Local106+ continue107+ }108+ if cur, err = s.element(x, t, section, cur); err != nil {109+ return err110+ }111+ case xml.EndElement:112+ if x.depth < 2 {113+ section = ""114+ }115+ }116+ }117+}118+119+// element reads one element inside a section of the styles part.120+func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string, cur *xlsxXf) (*xlsxXf, error) {121+ full := len(s.numFmts) >= x.g.p.lim.styles || len(s.fonts) >= x.g.p.lim.styles || len(s.xfs) >= x.g.p.lim.styles122+ switch {123+ case section == "numFmts" && se.Name.Local == "numFmt" && x.depth == 3:124+ if full {125+ return nil, fmt.Errorf("the workbook has more than %d styles: %w", x.g.p.lim.styles, errXLSXLimit)126+ }127+ id, err := strconv.Atoi(attrOr(se, "numFmtId", ""))128+ if err == nil {129+ s.numFmts[id], _ = attr(se, "formatCode")130+ }131+ case section == "fonts" && se.Name.Local == "font" && x.depth == 3:132+ if full {133+ return nil, fmt.Errorf("the workbook has more than %d fonts: %w", x.g.p.lim.styles, errXLSXLimit)134+ }135+ f, err := readFont(x)136+ if err != nil {137+ return nil, err138+ }139+ s.fonts = append(s.fonts, f)140+ case section == "cellXfs" && se.Name.Local == "xf" && x.depth == 3:141+ if full {142+ return nil, fmt.Errorf("the workbook has more than %d cell formats: %w", x.g.p.lim.styles, errXLSXLimit)143+ }144+ s.xfs = append(s.xfs, xlsxXf{145+ numFmt: intAttr(se, "numFmtId", -1), font: intAttr(se, "fontId", -1),146+ applyFont: boolAttr(se, "applyFont", true), applyAlign: boolAttr(se, "applyAlignment", true),147+ })148+ return &s.xfs[len(s.xfs)-1], nil149+ case section == "cellXfs" && se.Name.Local == "alignment" && x.depth == 4 && cur != nil:150+ switch h, _ := attr(se, "horizontal"); h {151+ case "left":152+ cur.align = sheet.AlignLeft153+ case "center", "centerContinuous":154+ cur.align = sheet.AlignCenter155+ case "right":156+ cur.align = sheet.AlignRight157+ }158+ }159+ return cur, nil160+}161+162+// readFont reads the rest of a <font>: bold, italic, strikethrough and163+// underline (any but u val="none").164+func readFont(x *xmlStream) (xlsxFont, error) {165+ var f xlsxFont166+ depth := x.depth167+ for {168+ t, err := x.next()169+ if err != nil {170+ return f, eofAsUnexpected(err)171+ }172+ switch t := t.(type) {173+ case xml.StartElement:174+ if x.depth != depth+1 {175+ continue176+ }177+ switch t.Name.Local {178+ case "b":179+ f.bold = boolAttr(t, "val", true)180+ case "i":181+ f.italic = boolAttr(t, "val", true)182+ case "strike":183+ f.strike = boolAttr(t, "val", true)184+ case "u":185+ f.underline = attrOr(t, "val", "single") != "none"186+ }187+ case xml.EndElement:188+ if x.depth < depth {189+ return f, nil190+ }191+ }192+ }193+}194+195+func attrOr(se xml.StartElement, name, def string) string {196+ if v, ok := attr(se, name); ok {197+ return v198+ }199+ return def200+}201+202+func intAttr(se xml.StartElement, name string, def int) int {203+ if v, ok := attr(se, name); ok {204+ if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {205+ return n206+ }207+ }208+ return def209+}210+211+// boolAttr reads an xsd:boolean attribute; an empty value is true, and212+// one that isn't a boolean is false.213+func boolAttr(se xml.StartElement, name string, def bool) bool {214+ v, ok := attr(se, name)215+ if !ok {216+ return def217+ }218+ if v = strings.TrimSpace(v); v == "" {219+ return true220+ }221+ b, _ := strconv.ParseBool(v)222+ return b223+}
A internal/fileio/xlsxwrite.go +361 -0
@@ -0,0 +1,361 @@
1+package fileio2+3+import (4+ "bufio"5+ "fmt"6+ "maps"7+ "math"8+ "slices"9+ "strconv"10+ "strings"11+ "unicode/utf8"12+13+ "github.com/FelineStateMachine/012/internal/formula"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// excelMaxText is the most characters an Excel cell holds; longer text18+// is cut, or Excel reports the file as damaged.19+const excelMaxText = 3276720+21+// xlsxWriter writes worksheets, sharing cell formats among them.22+type xlsxWriter struct {23+ styles *xlsxStyleTable24+ multi bool // examples name their sheet25+ buf []byte // a row's cells26+27+ // Formulas are written as their values when Excel has no equivalent,28+ // or when they name a sheet the file doesn't have, which Excel would29+ // refuse.30+ values valueCount31+ missing valueCount32+ missingSheet string // the sheet missing's example names33+ known map[string]bool // keys of the sheets written34+}35+36+// valueCount counts formulas written as values, keeping the first one's37+// address as an example.38+type valueCount struct {39+ n int40+ example string41+}42+43+func (v *valueCount) add(multi bool, ws string, a sheet.Addr) {44+ v.n++45+ if v.example == "" {46+ v.example = a.String()47+ if multi {48+ v.example = sheet.QuoteSheet(ws) + "!" + a.String()49+ }50+ }51+}52+53+// unknownSheet returns a sheet c's formula names that isn't written.54+func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {55+ for _, name := range c.Sheets {56+ if !w.known[formula.SheetKey(name)] {57+ return name, true58+ }59+ }60+ return "", false61+}62+63+// sheet writes a snapshot as a worksheet and returns the rows written.64+// Errors stick in bw.65+func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active bool) int {66+ r := snap.Range67+ bw.WriteString(xmlHead + `<worksheet xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">`)68+ fmt.Fprintf(bw, `<dimension ref="%s%d:%s%d"/>`, excelColName(r.From.Col+1), r.From.Row+1, excelColName(r.To.Col+1), r.To.Row+1)69+ bw.WriteString(`<sheetViews><sheetView workbookViewId="0"`)70+ if active {71+ bw.WriteString(` tabSelected="1"`)72+ }73+ bw.WriteString(`/></sheetViews><sheetFormatPr defaultRowHeight="15"/>`)74+ w.writeCols(bw, snap)75+ bw.WriteString(`<sheetData>`)76+ styled := slices.Sorted(maps.Keys(snap.RowFormats)) // rows with a style of their own, written even without cells77+ for row := r.From.Row; row <= r.To.Row; row++ {78+ styled = w.styledRows(bw, snap, styled, row)79+ _, rowStyled := snap.RowFormats[row]80+ if rowStyled {81+ styled = styled[1:]82+ }83+ b := w.buf[:0]84+ for col := r.From.Col; col <= r.To.Col; col++ {85+ a := sheet.Addr{Col: col, Row: row}86+ if c, ok := snap.Cells[a]; ok {87+ b = w.cell(b, ws, a, c)88+ }89+ }90+ if len(b) > 0 || rowStyled {91+ w.rowStart(bw, snap, row)92+ bw.Write(b)93+ bw.WriteString(`</row>`)94+ }95+ w.buf = b96+ }97+ w.styledRows(bw, snap, styled, sheet.MaxRows)98+ bw.WriteString(`</sheetData></worksheet>`)99+ return r.To.Row - r.From.Row + 1100+}101+102+func mapKeys(m map[int]int) func(func(int) bool) {103+ return func(yield func(int) bool) {104+ for k := range m {105+ if !yield(k) {106+ return107+ }108+ }109+ }110+}111+112+// cell appends snapshot cell c at a as a <c> element. A formula's value113+// is written as its cached result; a formula with no Excel equivalent,114+// or naming a sheet that isn't written, is written as its value alone,115+// and counted.116+func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte {117+ fx := w.formula(ws, a, c)118+ b = append(b, `<c r="`...)119+ b = append(b, excelColName(a.Col+1)...)120+ b = strconv.AppendInt(b, int64(a.Row+1), 10)121+ b = append(b, '"')122+ // A formula's inferred format is written too, so its result shows123+ // the same in Excel.124+ if !c.Format.IsZero() || !c.Style.IsZero() {125+ b = append(b, ` s="`...)126+ b = strconv.AppendInt(b, int64(w.styles.id(c.Format, c.Style)), 10)127+ b = append(b, '"')128+ }129+ typ, v, inline := cellValue(c, fx != "")130+ if typ != "" {131+ b = append(b, ` t="`...)132+ b = append(b, typ...)133+ b = append(b, '"')134+ }135+ if fx == "" && v == "" {136+ return append(b, "/>"...)137+ }138+ b = append(b, '>')139+ if fx != "" {140+ b = append(b, "<f>"...)141+ b = appendEscaped(b, fx, false)142+ b = append(b, "</f>"...)143+ }144+ switch {145+ case inline && fx == "":146+ b = append(b, `<is><t xml:space="preserve">`...)147+ b = appendEscaped(b, v, true)148+ b = append(b, "</t></is>"...)149+ case v != "":150+ b = append(b, "<v>"...)151+ b = appendEscaped(b, v, typ == "str")152+ b = append(b, "</v>"...)153+ }154+ return append(b, "</c>"...)155+}156+157+// formula is c's formula in Excel's syntax, or "" when it has none or158+// is written as its value, which it counts.159+func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) string {160+ if !c.Formula {161+ return ""162+ }163+ if name, ok := w.unknownSheet(c); ok {164+ if w.missing.n == 0 {165+ w.missingSheet = name166+ }167+ w.missing.add(w.multi, ws, a)168+ return ""169+ }170+ fx, ok := toExcelFormula(c.Input)171+ if !ok {172+ w.values.add(w.multi, ws, a)173+ }174+ return fx175+}176+177+// cellValue is a cell's value as Excel stores it: the type attribute,178+// the text, and whether text goes inline (<is>) rather than in <v>,179+// which only a formula's cached text does (t="str"). Errors are written180+// as text, as Excel recalculates on opening.181+func cellValue(c SnapCell, hasFormula bool) (typ, v string, inline bool) {182+ switch c.Value.Kind {183+ case sheet.Number:184+ n := toExcelSerial(c.Value.Num, c.Format)185+ if math.IsNaN(n) || math.IsInf(n, 0) {186+ return "e", "#NUM!", false187+ }188+ return "", numInput(n), false189+ case sheet.Bool:190+ if c.Value.Num != 0 {191+ return "b", "1", false192+ }193+ return "b", "0", false194+ case sheet.Text, sheet.Error:195+ s := c.Value.Str196+ if utf8.RuneCountInString(s) > excelMaxText {197+ s = string([]rune(s)[:excelMaxText])198+ }199+ if hasFormula {200+ return "str", s, false201+ }202+ return "inlineStr", s, true203+ }204+ return "", "", false205+}206+207+// escapeXML escapes s for XML text or, with attr, an attribute value.208+func escapeXML(s string, attr bool) string {209+ return string(appendXML(nil, s, attr))210+}211+212+func appendXML(b []byte, s string, attr bool) []byte {213+ for _, r := range s {214+ switch {215+ case r == '&':216+ b = append(b, "&amp;"...)217+ case r == '<':218+ b = append(b, "&lt;"...)219+ case r == '>':220+ b = append(b, "&gt;"...)221+ case r == '"' && attr:222+ b = append(b, "&quot;"...)223+ case r == '\r' || attr && (r == '\n' || r == '\t'):224+ b = append(b, "&#x"...)225+ b = strconv.AppendInt(b, int64(r), 16)226+ b = append(b, ';')227+ case !xmlChar(r):228+ b = utf8.AppendRune(b, utf8.RuneError)229+ default:230+ b = utf8.AppendRune(b, r)231+ }232+ }233+ return b234+}235+236+// appendEscaped appends s as XML text; with ooxml, as SpreadsheetML cell237+// text, where characters XML can't hold are written _xHHHH_ and a _x238+// that would read as such an escape is itself escaped (_x005F_).239+func appendEscaped(b []byte, s string, ooxml bool) []byte {240+ if !ooxml {241+ return appendXML(b, s, false)242+ }243+ for i := 0; i < len(s); {244+ r, size := utf8.DecodeRuneInString(s[i:])245+ switch {246+ case r == '_' && isOOXMLEscape(s[i:]):247+ b = append(b, "_x005F"...)248+ case !xmlChar(r) && r != utf8.RuneError && r <= 0xFFFF:249+ b = fmt.Appendf(b, "_x%04X_", r)250+ i += size251+ continue252+ }253+ b = appendXML(b, s[i:i+size], false)254+ i += size255+ }256+ return b257+}258+259+func isOOXMLEscape(s string) bool {260+ _, ok := ooxmlEscape([]byte(s[:min(len(s), 7)]))261+ return ok262+}263+264+// xmlChar reports whether XML 1.0 allows r in a document.265+func xmlChar(r rune) bool {266+ return r == '\t' || r == '\n' || r == '\r' ||267+ r >= 0x20 && r <= 0xD7FF || r >= 0xE000 && r <= 0xFFFD || r >= 0x10000 && r <= 0x10FFFF268+}269+270+// xlsxStyleTable collects the cell formats a workbook uses: each271+// distinct number format and text style is a cellXfs entry, with its272+// number format code and font.273+type xlsxStyleTable struct {274+ ids map[xlsxStyle]int275+ xfs []xlsxStyle // index 0 is the default276+ codes []string // custom number formats, from id 164277+ fonts []sheet.Style278+}279+280+func newXLSXStyleTable() *xlsxStyleTable {281+ return &xlsxStyleTable{ids: map[xlsxStyle]int{{}: 0}, xfs: []xlsxStyle{{}}, fonts: []sheet.Style{{}}}282+}283+284+// id is the index of the cell format for f and st.285+func (t *xlsxStyleTable) id(f sheet.Format, st sheet.Style) int {286+ key := xlsxStyle{f, st}287+ if id, ok := t.ids[key]; ok {288+ return id289+ }290+ id := len(t.xfs)291+ t.ids[key] = id292+ t.xfs = append(t.xfs, key)293+ return id294+}295+296+// xml is the styles part.297+func (t *xlsxStyleTable) xml() string {298+ var xfs strings.Builder299+ for _, x := range t.xfs {300+ numFmt := 0301+ if code := excelCode(x.format); code != "" {302+ i := slices.Index(t.codes, code)303+ if i < 0 {304+ i = len(t.codes)305+ t.codes = append(t.codes, code)306+ }307+ numFmt = 164 + i308+ }309+ font := fontOf(x.style)310+ i := slices.Index(t.fonts, font)311+ if i < 0 {312+ i = len(t.fonts)313+ t.fonts = append(t.fonts, font)314+ }315+ fmt.Fprintf(&xfs, `<xf numFmtId="%d" fontId="%d" fillId="0" borderId="0" xfId="0"`, numFmt, i)316+ if numFmt != 0 {317+ xfs.WriteString(` applyNumberFormat="1"`)318+ }319+ if i != 0 {320+ xfs.WriteString(` applyFont="1"`)321+ }322+ if x.style.Align == sheet.AlignAuto {323+ xfs.WriteString(`/>`)324+ continue325+ }326+ fmt.Fprintf(&xfs, ` applyAlignment="1"><alignment horizontal="%s"/></xf>`, x.style.Align)327+ }328+ var b strings.Builder329+ b.WriteString(xmlHead + `<styleSheet xmlns="` + sheetMain + `">`)330+ if len(t.codes) > 0 {331+ fmt.Fprintf(&b, `<numFmts count="%d">`, len(t.codes))332+ for i, code := range t.codes {333+ fmt.Fprintf(&b, `<numFmt numFmtId="%d" formatCode="%s"/>`, 164+i, escapeXML(code, true))334+ }335+ b.WriteString(`</numFmts>`)336+ }337+ fmt.Fprintf(&b, `<fonts count="%d">`, len(t.fonts))338+ for _, f := range t.fonts {339+ b.WriteString(`<font>`)340+ for _, on := range []struct {341+ set bool342+ tag string343+ }{{f.Bold, `<b/>`}, {f.Italic, `<i/>`}, {f.Strikethrough, `<strike/>`}, {f.Underline, `<u/>`}} {344+ if on.set {345+ b.WriteString(on.tag)346+ }347+ }348+ b.WriteString(`<sz val="11"/><name val="Calibri"/><family val="2"/></font>`)349+ }350+ b.WriteString(`</fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>` +351+ `<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +352+ `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`)353+ fmt.Fprintf(&b, `<cellXfs count="%d">%s</cellXfs>`, len(t.xfs), xfs.String())354+ b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`)355+ return b.String()356+}357+358+// fontOf is the part of a style a font holds.359+func fontOf(st sheet.Style) sheet.Style {360+ return sheet.Style{Bold: st.Bold, Italic: st.Italic, Underline: st.Underline, Strikethrough: st.Strikethrough}361+}
M internal/formula/formula_test.go +33 -0
@@ -2,6 +2,7 @@ package formula
22 33 import (44 "reflect"5+ "strings"56 "testing"67 )78
@@ -274,6 +275,10 @@ func FuzzPrint(f *testing.F) {
274275 for _, s := range []string{275276 "=A1*2", "=$A$1+A$2-$B3", "=SUM($A1:B$3)", "=-2^-2%", "=1-(2-3)", "=#NOT#A1=1#OR#B1",276277 "=2^(3^2)", `=IF(A1>2,"x""y",#REF!)`, "=1.5E-3^2", "=@SUM(A1..B3)", "=(1+2)%*-A1",278+ // At and past the nesting cap.279+ "=" + strings.Repeat("(", MaxDepth-1) + "1" + strings.Repeat(")", MaxDepth-1),280+ "=" + strings.Repeat("-(", MaxDepth/2) + "1" + strings.Repeat(")", MaxDepth/2),281+ "=" + strings.Repeat("(", MaxDepth+1) + "1" + strings.Repeat(")", MaxDepth+1),277282 } {278283 f.Add(s)279284 }
@@ -293,6 +298,34 @@ func FuzzPrint(f *testing.F) {
293298 })294299 }295300 301+// Nesting is capped at MaxDepth levels, with the error at the level302+// past it; anything within the cap parses.303+func TestParseDepth(t *testing.T) {304+ nested := func(n int) string {305+ return "=" + strings.Repeat("(", n) + "1" + strings.Repeat(")", n)306+ }307+ if _, err := Parse(nested(MaxDepth-1), testFuncs); err != nil {308+ t.Errorf("%d parentheses: %v", MaxDepth-1, err)309+ }310+ for _, in := range []string{311+ nested(MaxDepth),312+ nested(100_000),313+ "=" + strings.Repeat("-", 5000) + "1",314+ "=" + strings.Repeat("SUM(", 2000) + "1" + strings.Repeat(")", 2000),315+ "=" + strings.Repeat("(", 5000),316+ } {317+ _, err := Parse(in, testFuncs)318+ pe, ok := err.(*ParseError)319+ if !ok || !strings.HasPrefix(pe.Msg, "Formula is nested too deeply") {320+ t.Errorf("Parse(%.20q...) error = %v", in, err)321+ continue322+ }323+ if pe.Pos < MaxDepth || pe.Pos > len(in) {324+ t.Errorf("Parse(%.20q...) error at %d", in, pe.Pos)325+ }326+ }327+}328+296329 func TestParseErrors(t *testing.T) {297330 tests := []struct {298331 in string
M internal/formula/parse.go +24 -0
@@ -23,11 +23,21 @@ const (
2323 percentPower = 802424 )2525 26+// MaxDepth is how deeply a formula may nest: parentheses, function calls27+// and prefix operators each open a level (an operator of higher28+// precedence may add one or two). Excel allows 64 nested functions; this29+// is far more than a formula written by hand needs, and it bounds the30+// recursion of everything that walks a formula (the parser, printer,31+// evaluator and reference rewriting), so a pathological file fails to32+// parse rather than exhausting the stack.33+const MaxDepth = 102434+2635 type parser struct {2736 src string // the formula without its "=", for names' original spelling2837 toks []token2938 pos int3039 funcs Funcs40+ depth int // of expr calls, see MaxDepth3141 }3242 3343 // Parse parses a formula, finding the functions it calls with funcs. src
@@ -73,7 +83,21 @@ func (p *parser) isOp(op string) bool {
7383 return t.kind == tokOp && t.text == op7484 }7585 86+// expr parses an expression whose operators bind tighter than minPower.87+// Every level of nesting passes through here, so here it's counted.7688 func (p *parser) expr(minPower int) (Node, error) {89+ if p.depth >= MaxDepth {90+ return nil, &ParseError{p.peek().pos, fmt.Sprintf("Formula is nested too deeply (more than %d levels)", MaxDepth)}91+ }92+ p.depth++93+ n, err := p.climb(minPower)94+ p.depth--95+ return n, err96+}97+98+// climb parses by precedence climbing: a prefix, then infix and postfix99+// operators binding tighter than minPower.100+func (p *parser) climb(minPower int) (Node, error) {77101 left, err := p.prefix()78102 if err != nil {79103 return nil, err
M internal/jev/key.go +3 -93
@@ -1,96 +1,6 @@
11 package jev22 3-import (4- "encoding/json"5- "fmt"6- "maps"7- "slices"8- "strconv"9- "strings"3+import "github.com/FelineStateMachine/012/internal/sheet"104 11- "github.com/FelineStateMachine/012/internal/sheet"12-)13-14-// Key identifies a question by its content. JEV cells are volatile, so15-// every recalculation looks each one up: with thousands of them,16-// encoding the key with encoding/json took most of the time an answer17-// spent in recalc. The shapes a RemoteCall holds are encoded by hand,18-// each value tagged and each string length-prefixed so different calls19-// never share a key; anything else falls back to JSON.20-func Key(c sheet.RemoteCall) string {21- var b strings.Builder22- b.Grow(64 + len(c.Instructions))23- str(&b, c.Kind)24- str(&b, c.Instructions)25- if appendValue(&b, c.State) && appendValue(&b, c.Criteria) {26- return b.String()27- }28- j, err := json.Marshal(c)29- if err != nil {30- return fmt.Sprintf("%#v", c)31- }32- return "j" + string(j)33-}34-35-func str(b *strings.Builder, s string) {36- b.WriteString(strconv.Itoa(len(s)))37- b.WriteByte(':')38- b.WriteString(s)39-}40-41-// appendValue encodes v and reports whether it is a shape it knows.42-func appendValue(b *strings.Builder, v any) bool {43- switch v := v.(type) {44- case nil:45- b.WriteByte('n')46- case string:47- b.WriteByte('s')48- str(b, v)49- case float64:50- b.WriteByte('f')51- b.WriteString(strconv.FormatFloat(v, 'g', -1, 64))52- b.WriteByte(';')53- case bool:54- if v {55- b.WriteByte('T')56- } else {57- b.WriteByte('F')58- }59- case [2]string:60- b.WriteByte('p')61- str(b, v[0])62- str(b, v[1])63- case []string:64- b.WriteByte('l')65- b.WriteString(strconv.Itoa(len(v)))66- for _, s := range v {67- str(b, s)68- }69- case map[string]string:70- b.WriteByte('m')71- b.WriteString(strconv.Itoa(len(v)))72- for _, k := range slices.Sorted(maps.Keys(v)) {73- str(b, k)74- str(b, v[k])75- }76- case []any:77- b.WriteByte('a')78- b.WriteString(strconv.Itoa(len(v)))79- for _, e := range v {80- if !appendValue(b, e) {81- return false82- }83- }84- case [][]any:85- b.WriteByte('r')86- b.WriteString(strconv.Itoa(len(v)))87- for _, row := range v {88- if !appendValue(b, row) {89- return false90- }91- }92- default:93- return false94- }95- return true96-}5+// Key identifies a question by its content; see sheet.RemoteCall.Key.6+func Key(c sheet.RemoteCall) string { return c.Key() }
M internal/serve/serve.go +1 -1
M internal/sheet/decimal.go +108 -24
@@ -18,7 +18,7 @@ import (
1818 // compute in decimal:1919 //2020 // - the operators + - * / and the postfix %21-// - SUM and AVERAGE21+// - SUM, AVERAGE, PRODUCT, SUMIF, SUMIFS and SUMPRODUCT2222 // - ROUND, ROUNDUP, ROUNDDOWN and TRUNC2323 //2424 // Each step reads its float64 operands as the shortest decimal that
@@ -102,10 +102,54 @@ func decRound(x float64, places int, mode numfmt.Rounding) (Value, bool) {
102102 return fromDec(&z)103103 }104104 105-// decSum adds the numbers SUM would, in decimal, and counts them.106-func decSum(args []Node, get lookup) (sum apd.Decimal, n int, e *Value, ok bool) {107- ok = true108- var x apd.Decimal105+// decAcc accumulates a sum or a product in decimal; ok turns false when106+// a step can't be represented, and the caller falls back to binary.107+type decAcc struct {108+ d, x apd.Decimal109+ ok bool110+}111+112+func newDecAcc(start int64) *decAcc {113+ a := &decAcc{ok: true}114+ a.d.SetInt64(start)115+ return a116+}117+118+func (a *decAcc) add(f float64) { a.step(f, decCtx.Add) }119+func (a *decAcc) mul(f float64) { a.step(f, decCtx.Mul) }120+121+func (a *decAcc) step(f float64, op func(d, x, y *apd.Decimal) (apd.Condition, error)) {122+ if !a.ok {123+ return124+ }125+ if _, err := a.x.SetFloat64(f); err != nil {126+ a.ok = false127+ } else if _, err := op(&a.d, &a.d, &a.x); err != nil {128+ a.ok = false129+ }130+}131+132+// addDec adds a decimal to the sum.133+func (a *decAcc) addDec(d *apd.Decimal) {134+ if !a.ok {135+ return136+ }137+ if _, err := decCtx.Add(&a.d, &a.d, d); err != nil {138+ a.ok = false139+ }140+}141+142+// result is the accumulated value, or false to fall back.143+func (a *decAcc) result() (Value, bool) {144+ if !a.ok {145+ return Value{}, false146+ }147+ return fromDec(&a.d)148+}149+150+// decFold feeds the numbers SUM and PRODUCT take to step and counts151+// them: in ranges only numbers, direct arguments coerced.152+func decFold(args []Node, get lookup, step func(float64)) (n int, e *Value) {109153 e = each(args, get, func(v Value, direct bool) *Value {110154 switch v.Kind {111155 case Error:
@@ -121,49 +165,89 @@ func decSum(args []Node, get lookup) (sum apd.Decimal, n int, e *Value, ok bool)
121165 return err122166 }123167 n++124- if _, err := x.SetFloat64(f); err != nil {125- ok = false126- } else if _, err := decCtx.Add(&sum, &sum, &x); err != nil {127- ok = false128- }168+ step(f)129169 return nil130170 })131- return sum, n, e, ok171+ return n, e172+}173+174+// decTerms builds the decimal twin of a function that visits the terms175+// it adds (SUMIF, SUMIFS).176+func decTerms(terms func([]Node, lookup, func(float64)) *Value) func([]Node, lookup) (Value, bool) {177+ return func(args []Node, get lookup) (Value, bool) {178+ acc := newDecAcc(0)179+ if e := terms(args, get, acc.add); e != nil {180+ return *e, true181+ }182+ return acc.result()183+ }184+}185+186+// decSumProduct is SUMPRODUCT with each entry's product and the sum in187+// decimal.188+func decSumProduct(args []Node, get lookup) (Value, bool) {189+ acc := newDecAcc(0)190+ e := sumProductTerms(args, get, func(fs []float64) {191+ p := newDecAcc(1)192+ for _, f := range fs {193+ p.mul(f)194+ }195+ if !p.ok {196+ acc.ok = false197+ }198+ acc.addDec(&p.d)199+ })200+ if e != nil {201+ return *e, true202+ }203+ return acc.result()132204 }133205 134206 // decEvals are the decimal versions of functions, by name. Each reports135207 // false to fall back to the binary version.136208 var decEvals = map[string]func(args []Node, get lookup) (Value, bool){137209 "SUM": func(args []Node, get lookup) (Value, bool) {138- sum, _, e, ok := decSum(args, get)139- if e != nil {210+ acc := newDecAcc(0)211+ if _, e := decFold(args, get, acc.add); e != nil {140212 return *e, true141213 }142- if !ok {143- return Value{}, false144- }145- return fromDec(&sum)214+ return acc.result()146215 },147216 "AVERAGE": func(args []Node, get lookup) (Value, bool) {148- sum, n, e, ok := decSum(args, get)217+ acc := newDecAcc(0)218+ n, e := decFold(args, get, acc.add)149219 switch {150220 case e != nil:151221 return *e, true152222 case n == 0:153223 return ErrDiv0, true154- case !ok:224+ case !acc.ok:155225 return Value{}, false156226 }157227 var q apd.Decimal158- if _, err := decCtx.Quo(&q, &sum, apd.New(int64(n), 0)); err != nil {228+ if _, err := decCtx.Quo(&q, &acc.d, apd.New(int64(n), 0)); err != nil {159229 return Value{}, false160230 }161231 return fromDec(&q)162232 },163- "ROUND": decRounder(numfmt.HalfUp),164- "ROUNDUP": decRounder(numfmt.Up),165- "ROUNDDOWN": decRounder(numfmt.Down),166- "TRUNC": decRounder(numfmt.Down),233+ "PRODUCT": func(args []Node, get lookup) (Value, bool) {234+ acc := newDecAcc(1)235+ n, e := decFold(args, get, acc.mul)236+ switch {237+ case e != nil:238+ return *e, true239+ case n == 0:240+ return num(0), true241+ }242+ return acc.result()243+ },244+ "SUMIF": decTerms(sumIfTerms),245+ "SUMIFS": decTerms(sumIfsTerms),246+ "SUMPRODUCT": decSumProduct,247+ "ROUND": decRounder(numfmt.HalfUp),248+ "ROUNDUP": decRounder(numfmt.Up),249+ "ROUNDDOWN": decRounder(numfmt.Down),250+ "TRUNC": decRounder(numfmt.Down),167251 }168252 169253 func decRounder(mode numfmt.Rounding) func([]Node, lookup) (Value, bool) {
M internal/sheet/decimal_test.go +17 -0
@@ -69,6 +69,23 @@ func TestDecimalArithmetic(t *testing.T) {
6969 {"average of nothing", "=AVERAGE(A1:A2)", nil, "#DIV/0!", "#DIV/0!"},7070 {"sum ignores text in ranges", "=SUM(A1:A3)", []string{"0.1", "x", "0.2"}, "0.30000000000000004", "0.3"},7171 {"huge sum falls back", "=SUM(1E308, 1E308)", nil, "#NUM!", "#NUM!"},72+ // PRODUCT, SUMIF, SUMIFS and SUMPRODUCT have decimal twins too.73+ {"product", "=PRODUCT(A1:A2)", []string{"1.1", "1.1"}, "1.2100000000000002", "1.21"},74+ {"product of direct", "=PRODUCT(0.07, 3)", nil, "0.21000000000000002", "0.21"},75+ {"product skips text in ranges", "=PRODUCT(A1:A3)", []string{"1.1", "x", "1.1"}, "1.2100000000000002", "1.21"},76+ {"product of nothing", "=PRODUCT(A1:A2)", nil, "0", "0"},77+ {"product of text", `=PRODUCT("a")`, nil, "#VALUE!", "#VALUE!"},78+ {"huge product falls back", "=PRODUCT(1E308, 10)", nil, "#NUM!", "#NUM!"},79+ {"sumif", `=SUMIF(A1:A10, ">0")`, tenCents, "0.9999999999999999", "1"},80+ {"sumif equals", `=SUMIF(A1:A10, ">0")=1`, tenCents, "FALSE", "TRUE"},81+ {"sumif with sum range", `=SUMIF(A1:A3, "<>x", A1:A3)`, []string{"0.1", "x", "0.2"}, "0.30000000000000004", "0.3"},82+ {"sumif error", `=SUMIF(A1:A2, ">0")`, []string{"1", "=1/0"}, "1", "1"},83+ {"sumifs", `=SUMIFS(A1:A10, A1:A10, "0.1")`, tenCents, "0.9999999999999999", "1"},84+ {"sumifs mismatched", `=SUMIFS(A1:A10, A1:A9, ">0")`, tenCents, "#VALUE!", "#VALUE!"},85+ {"sumproduct", "=SUMPRODUCT(A1:A3, A1:A3)", []string{"0.1", "0.2", "0.3"}, "0.14", "0.14"},86+ {"sumproduct of cents", "=SUMPRODUCT(A1:A2, A3:A4)", []string{"0.1", "0.2", "3", "3"}, "0.9000000000000001", "0.9"},87+ {"sumproduct mismatched", "=SUMPRODUCT(A1:A2, A1:A3)", []string{"1", "2", "3"}, "#VALUE!", "#VALUE!"},88+ {"sumproduct error", "=SUMPRODUCT(A1:A2)", []string{"1", "=1/0"}, "#DIV/0!", "#DIV/0!"},7289 }7390 show := func(v Value) string {7491 if v.Kind == Number {
M internal/sheet/eval.go +8 -1
A internal/sheet/evaluate.go +183 -0
@@ -0,0 +1,183 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Evaluation is lazy and recursive: a formula reading a dirty cell10+// evaluates it first, so a chain of formulas (each row adding to the row11+// above) recurses once per link. The parser caps how deeply one formula12+// nests (formula.MaxDepth), but a chain can run through every cell of a13+// sheet, two million links, which would take more than the goroutine's14+// stack. So evaluation counts how deep it is (w.depth: cells and15+// operators, nested), and a cell it reaches past maxEvalDepth is put off:16+// the evaluation in progress is abandoned (its cells go back to dirty),17+// the cell is evaluated on its own, from the top of the stack, and then18+// what was abandoned is tried again, finding it done. Values come out as19+// if evaluated in one go; a long chain costs about twice the work.20+//21+// Cells put off are "deferred" until they're evaluated: each depends on22+// the one put off after it, so reaching one again is a cycle, as reaching23+// a cell being evaluated is.24+25+// maxEvalDepth bounds evaluation's nesting, in cells and operators: about26+// 30 MB of stack. A var so tests can lower it.27+var maxEvalDepth = 1 << 1628+29+// tooDeep abandons an evaluation that went past maxEvalDepth.30+type tooDeep struct{}31+32+// evaluator is the state of one evaluate.33+type evaluator struct {34+ w *Workbook35+ pending []loc // cells put off, each needed by the one before36+}37+38+// evaluate computes the cells marked dirty in each sheet's calc. Cells39+// are evaluated lazily in dependency order: reading a dirty cell40+// evaluates it first. A cell that is reached again while it is still41+// being evaluated is part of a cycle and becomes ERR. The state lives on42+// the sheets, keyed by address as on one sheet, and each sheet's lookup43+// is made once, so evaluating a formula allocates nothing for sheets.44+func (w *Workbook) evaluate() {45+ w.Circular = false46+ e := &evaluator{w: w}47+ // Running aggregates shared by the recalculation (rangememo.go). A48+ // cell put off abandons an extension part way: its checkpoints stop49+ // at the last row it finished, and the next read goes on from there.50+ memo := &aggMemo{w: w, m: map[aggKey]*runAgg{}}51+ for _, s := range w.sheets {52+ s.version++53+ s.hidden.valid = false // values may have changed what the filter hides54+ s.calcGet = w.lookupOn(s, e.compute)55+ s.calcGet.memo = memo56+ s.calcFmt = w.formatFrom(s)57+ }58+ for _, s := range w.sheets {59+ for !e.sweep(s) {60+ e.settle()61+ }62+ }63+ for _, s := range w.sheets {64+ s.calc, s.calcGet, s.calcFmt = nil, nil, nil65+ }66+}67+68+// compute returns the value of the cell at a, evaluating it first if it's69+// dirty. Every cell a formula reads comes through here, so it looks each70+// map up once: a SUM over 8192 cells makes 8192 calls.71+func (e *evaluator) compute(s *Sheet, a Addr) Value {72+ w := e.w73+ c := s.cells.get(a)74+ switch st := s.calc[a]; {75+ case c == nil:76+ return Value{}77+ case st == visiting, st == deferred:78+ w.Circular = true79+ return ErrRef80+ case st != dirty:81+ return c.Value82+ case c.derived: // a pivot's result, set when the pivot was computed83+ s.calc[a] = done84+ return c.Value85+ case c.expr != nil && w.depth >= maxEvalDepth:86+ s.calc[a] = deferred87+ e.pending = append(e.pending, loc{s, a})88+ panic(tooDeep{})89+ }90+ c.auto = Format{}91+ switch {92+ case c.Input == "":93+ c.Value = Value{}94+ case c.expr == nil && c.Format.Kind == FmtText:95+ c.Value = Value{Kind: Text, Str: c.Input}96+ case c.expr == nil:97+ c.Value = Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}98+ default:99+ if n, lit := c.expr.(formula.Num); lit { // a number typed in100+ c.Value = num(n.V)101+ } else {102+ e.formula(s, a, c)103+ }104+ }105+ s.calc[a] = done106+ s.cells.changed(a)107+ return c.Value108+}109+110+// formula evaluates the formula in c, at a, which may read other cells:111+// it's visiting meanwhile.112+func (e *evaluator) formula(s *Sheet, a Addr, c *Cell) {113+ w := e.w114+ s.calc[a] = visiting115+ w.depth++116+ expr := s.bound(c)117+ outer := w.evaluating118+ w.evaluating = loc{s, a}119+ c.Value = eval(w.arith(expr), s.calcGet)120+ w.evaluating = outer121+ c.auto = inferFormat(expr, s.calcFmt)122+ w.depth--123+}124+125+// sweep evaluates s's dirty cells, reporting false if it went too deep126+// and put off a cell.127+func (e *evaluator) sweep(s *Sheet) (finished bool) {128+ defer e.abandon(&finished)129+ for a, st := range s.calc {130+ if st == dirty {131+ e.compute(s, a)132+ }133+ }134+ return true135+}136+137+// settle evaluates the cells put off, deepest first, putting off more on138+// the way if need be.139+func (e *evaluator) settle() {140+ for len(e.pending) > 0 {141+ top := e.pending[len(e.pending)-1]142+ if top.s.calc[top.a] == deferred {143+ top.s.calc[top.a] = dirty144+ }145+ if e.try(top) {146+ e.pending = e.pending[:len(e.pending)-1]147+ }148+ }149+}150+151+// try evaluates the cell at l, reporting false if it went too deep and152+// put off a cell, having undone what it had started.153+func (e *evaluator) try(l loc) (finished bool) {154+ defer e.abandon(&finished)155+ e.compute(l.s, l.a)156+ return true157+}158+159+// abandon, deferred, recovers from an evaluation that went too deep:160+// the cells it was evaluating go back to dirty, those put off stay161+// deferred, and finished is set false.162+func (e *evaluator) abandon(finished *bool) {163+ r := recover()164+ if r == nil {165+ return166+ }167+ if _, deep := r.(tooDeep); !deep {168+ panic(r)169+ }170+ for _, s := range e.w.sheets {171+ for a, st := range s.calc {172+ if st == visiting {173+ s.calc[a] = dirty174+ }175+ }176+ }177+ for _, p := range e.pending {178+ p.s.calc[p.a] = deferred179+ }180+ e.w.depth = 0181+ e.w.evaluating = loc{}182+ *finished = false183+}
A internal/sheet/evaluate_test.go +103 -0
@@ -0,0 +1,103 @@
1+package sheet2+3+import (4+ "fmt"5+ "os"6+ "runtime/debug"7+ "testing"8+)9+10+// withEvalDepth lowers maxEvalDepth for a test.11+func withEvalDepth(t *testing.T, n int) {12+ t.Helper()13+ old := maxEvalDepth14+ maxEvalDepth = n15+ t.Cleanup(func() { maxEvalDepth = old })16+}17+18+// chain fills column col, rows 1 to n, with =A(row-1)+1 below a 1, so19+// the last is n.20+func chain(s *Sheet, col, n int) {21+ for row := range n {22+ in := "1"23+ if row > 0 {24+ in = "=" + Addr{Col: col, Row: row - 1}.String() + "+1"25+ }26+ s.Load(Addr{Col: col, Row: row}, in, Format{}, Style{})27+ }28+}29+30+// Evaluation past maxEvalDepth puts cells off and comes back to them:31+// values come out as if evaluated in one go.32+func TestDeepChains(t *testing.T) {33+ withEvalDepth(t, 10)34+ s := New()35+ chain(s, 0, 1000)36+ // Deep formulas in a chain: each cell nests 8 levels.37+ for row := range 300 {38+ in := "=1"39+ if row > 0 {40+ in = "=((((((((" + Addr{Col: 1, Row: row - 1}.String() + "+1))))))))"41+ }42+ s.Load(Addr{Col: 1, Row: row}, in, Format{}, Style{})43+ }44+ s.RecalcAll()45+ if got := s.Value(Addr{Row: 999}); got != num(1000) {46+ t.Errorf("A1000 = %+v", got)47+ }48+ if got := s.Value(Addr{Col: 1, Row: 299}); got != num(300) {49+ t.Errorf("B300 = %+v", got)50+ }51+ if s.Book().Circular {52+ t.Error("a chain was taken for a cycle")53+ }54+ // An edit at the top recalculates the chain the same way.55+ s.Set(at("A1"), "5")56+ if got := s.Value(Addr{Row: 999}); got != num(1004) {57+ t.Errorf("after an edit, A1000 = %+v", got)58+ }59+}60+61+// A cycle longer than maxEvalDepth is still a cycle.62+func TestDeepCycle(t *testing.T) {63+ withEvalDepth(t, 10)64+ s := New()65+ chain(s, 0, 500)66+ s.Set(at("A1"), "=A500+1")67+ if !s.Book().Circular {68+ t.Error("a cycle of 500 cells wasn't found")69+ }70+ if got := s.Value(at("A250")); got.Kind != Error {71+ t.Errorf("A250, in the cycle, = %+v", got)72+ }73+ s.Set(at("A1"), "1")74+ if s.Book().Circular || s.Value(at("A500")) != num(500) {75+ t.Errorf("after breaking the cycle: circular %v, A500 = %+v", s.Book().Circular, s.Value(at("A500")))76+ }77+}78+79+// A chain through every cell of a full sheet fits in a bounded stack.80+// Slow (2 M cells, 1 GB): set SHEET_FULL_CHAIN=1 to run it.81+func TestFullSheetChain(t *testing.T) {82+ if os.Getenv("SHEET_FULL_CHAIN") == "" {83+ t.Skip("set SHEET_FULL_CHAIN=1 to evaluate a chain through all 2 M cells")84+ }85+ defer debug.SetMaxStack(debug.SetMaxStack(64 << 20))86+ s := New()87+ for col := range MaxCols {88+ for row := range MaxRows {89+ in := "1"90+ switch {91+ case row > 0:92+ in = "=" + Addr{Col: col, Row: row - 1}.String() + "+1"93+ case col > 0:94+ in = "=" + Addr{Col: col - 1, Row: MaxRows - 1}.String() + "+1"95+ }96+ s.Load(Addr{Col: col, Row: row}, in, Format{}, Style{})97+ }98+ }99+ s.RecalcAll()100+ if got, want := s.Value(Addr{Col: MaxCols - 1, Row: MaxRows - 1}), num(MaxCols*MaxRows); got != want {101+ t.Errorf("IV8192 = %+v, want %v", got, fmt.Sprint(want.Num))102+ }103+}
M internal/sheet/file.go +6 -0
M internal/sheet/functions_jev.go +1 -0
M internal/sheet/functions_jev_test.go +42 -0
@@ -109,3 +109,45 @@ func TestJEVWithoutKey(t *testing.T) {
109109 t.Errorf("no key: %+v", s.Value(at("A1")))110110 }111111 }112+113+// An answer recalculates the formulas that asked it and what reads them,114+// not every JEV formula.115+func TestRecalcAnswered(t *testing.T) {116+ f := &fakeRemote{answers: map[string]RemoteAnswer{}}117+ s := New()118+ s.SetRemote(f)119+ s.Set(at("A1"), `=JEV.TEST("x", "Q1")`)120+ s.Set(at("A2"), `=JEV.TEST("x", "Q2")`)121+ s.Set(at("A3"), `=IF(JEV.TEST("x", "Q1"), "yes", "no")`)122+ s.Set(at("B1"), `=A1`)123+ if !IsPending(s.Value(at("A1"))) || !IsPending(s.Value(at("A3"))) {124+ t.Fatalf("A1 %+v, A3 %+v", s.Value(at("A1")), s.Value(at("A3")))125+ }126+ f.answers["Q1"] = RemoteAnswer{Noul: 0.9}127+ f.asked = nil128+ q1 := RemoteCall{Kind: "noul", State: "x", Instructions: "Q1", Criteria: [2]string{}}129+ s.Book().RecalcAnswered([]RemoteCall{q1})130+ for _, c := range f.asked {131+ if c.Instructions != "Q1" {132+ t.Errorf("an answer to Q1 asked %q again", c.Instructions)133+ }134+ }135+ if len(f.asked) != 2 {136+ t.Errorf("%d questions looked up, want the 2 cells asking Q1", len(f.asked))137+ }138+ if got := s.Value(at("B1")); got != boolean(true) {139+ t.Errorf("B1, reading A1, = %+v", got)140+ }141+ if got := s.Value(at("A3")); got.Str != "yes" {142+ t.Errorf("A3 = %+v", got)143+ }144+ if !IsPending(s.Value(at("A2"))) {145+ t.Errorf("A2 = %+v", s.Value(at("A2")))146+ }147+ // Answered questions are forgotten; an unknown one does nothing.148+ f.asked = nil149+ s.Book().RecalcAnswered([]RemoteCall{q1, {Kind: "noul", Instructions: "Q9"}})150+ if len(f.asked) != 0 {151+ t.Errorf("answering again looked up %d questions", len(f.asked))152+ }153+}
A internal/sheet/hidden.go +84 -0
@@ -0,0 +1,84 @@
1+package sheet2+3+import (4+ "errors"5+ "slices"6+)7+8+// Hidden sheets, as Sheets' Hide sheet: a hidden sheet keeps its cells9+// and stays in formulas, named ranges and pivot sources; it is left out10+// of the tabs and of moving between sheets, and View > Hidden sheets11+// shows it again. At least one sheet is always visible. Hiding and12+// showing are undo steps that snapshot the sheet list (historysheets.go),13+// and files keep the flag in an optional field older builds ignore.14+15+// Hidden reports whether the sheet is hidden.16+func (s *Sheet) Hidden() bool { return s.tabHidden }17+18+// Visible returns the sheets that aren't hidden, in tab order.19+func (w *Workbook) Visible() []*Sheet {20+ return slices.DeleteFunc(slices.Clone(w.sheets), (*Sheet).Hidden)21+}22+23+// HiddenSheets returns the hidden sheets, in tab order.24+func (w *Workbook) HiddenSheets() []*Sheet {25+ return slices.DeleteFunc(slices.Clone(w.sheets), func(s *Sheet) bool { return !s.tabHidden })26+}27+28+// visibleCount is how many sheets aren't hidden.29+func (w *Workbook) visibleCount() int {30+ n := 031+ for _, s := range w.sheets {32+ if !s.tabHidden {33+ n++34+ }35+ }36+ return n37+}38+39+// errLastVisible is why the last visible sheet can't be hidden or deleted.40+var errLastVisible = errors.New("A spreadsheet needs at least one visible sheet")41+42+// HideSheet hides s, as one undo step. The last visible sheet can't be43+// hidden.44+func (w *Workbook) HideSheet(s *Sheet) error {45+ switch {46+ case !s.live:47+ return errors.New("That sheet was deleted")48+ case s.tabHidden:49+ return nil50+ case w.visibleCount() == 1:51+ return errLastVisible52+ }53+ w.change(s, "hide sheet "+s.name, Rect{}, func() {54+ w.recordSheets()55+ s.tabHidden = true56+ })57+ return nil58+}59+60+// UnhideSheet shows s again, as one undo step.61+func (w *Workbook) UnhideSheet(s *Sheet) error {62+ switch {63+ case !s.live:64+ return errors.New("That sheet was deleted")65+ case !s.tabHidden:66+ return nil67+ }68+ w.change(s, "show sheet "+s.name, Rect{}, func() {69+ w.recordSheets()70+ s.tabHidden = false71+ })72+ return nil73+}74+75+// settleHidden keeps a loaded workbook showable: if every sheet is76+// hidden the first is shown, and a hidden sheet isn't the active one.77+func (w *Workbook) settleHidden() {78+ if w.visibleCount() == 0 {79+ w.sheets[0].tabHidden = false80+ }81+ if w.sheets[w.Active()].tabHidden {82+ w.active = w.Index(w.Visible()[0])83+ }84+}
A internal/sheet/hidden_test.go +90 -0
@@ -0,0 +1,90 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+// Hiding a sheet keeps it in formulas, is one undo step each way, and10+// the last visible sheet can't be hidden or deleted.11+func TestHideSheet(t *testing.T) {12+ w := bookOf(t,13+ page{"Summary", map[string]string{"A1": "=Data!A1*2"}},14+ page{"Data", map[string]string{"A1": "21"}},15+ )16+ data, summary := w.Lookup("Data"), w.Lookup("Summary")17+ if err := w.HideSheet(data); err != nil {18+ t.Fatal(err)19+ }20+ if !data.Hidden() || len(w.Visible()) != 1 || w.HiddenSheets()[0] != data {21+ t.Fatalf("hidden %v, visible %d", data.Hidden(), len(w.Visible()))22+ }23+ if got := show(t, w, "Summary!A1"); got != "42" {24+ t.Errorf("formula reading the hidden sheet = %s", got)25+ }26+ data.Set(at("A1"), "5")27+ if got := show(t, w, "Summary!A1"); got != "10" {28+ t.Errorf("after editing the hidden sheet = %s", got)29+ }30+ if err := w.HideSheet(summary); err != errLastVisible {31+ t.Errorf("hiding the last visible sheet: %v", err)32+ }33+ if err := w.DeleteSheet(summary); err != errLastVisible {34+ t.Errorf("deleting the last visible sheet: %v", err)35+ }36+ c, ok := w.Undo()37+ if !ok || c.Label != "edit A1" {38+ t.Fatalf("undo %+v", c)39+ }40+ c, ok = w.Undo()41+ if !ok || c.Label != "hide sheet Data" || data.Hidden() {42+ t.Fatalf("undo hide: %+v, hidden %v", c, data.Hidden())43+ }44+ w.Redo()45+ if !data.Hidden() {46+ t.Error("redo didn't hide")47+ }48+ if err := w.UnhideSheet(data); err != nil || data.Hidden() {49+ t.Fatalf("unhide: %v", err)50+ }51+ if c, _ := w.Undo(); c.Label != "show sheet Data" || !data.Hidden() {52+ t.Errorf("undo unhide: %+v", c)53+ }54+}55+56+// Hidden sheets are saved in an optional field; a file whose sheets are57+// all hidden, or that was showing a hidden one, opens on a visible one.58+func TestHiddenSheetFile(t *testing.T) {59+ w := bookOf(t,60+ page{"Summary", map[string]string{"A1": "=Data!A1*2"}},61+ page{"Data", map[string]string{"A1": "21"}},62+ )63+ w.HideSheet(w.Lookup("Data"))64+ var b bytes.Buffer65+ if err := w.Write(&b); err != nil {66+ t.Fatal(err)67+ }68+ out := b.String()69+ if !strings.Contains(out, `"version": 4,`) || strings.Count(out, `"hidden": true`) != 1 ||70+ !strings.Contains(out, "\"name\": \"Data\",\n \"hidden\": true,") {71+ t.Fatalf("file:\n%s", out)72+ }73+ r, err := ReadBook(strings.NewReader(out))74+ if err != nil {75+ t.Fatal(err)76+ }77+ if !r.Lookup("Data").Hidden() || r.Lookup("Summary").Hidden() || show(t, r, "Summary!A1") != "42" {78+ t.Errorf("read back: Data hidden %v", r.Lookup("Data").Hidden())79+ }80+81+ all := strings.Replace(out, `"name": "Summary",`, `"name": "Summary", "hidden": true,`, 1)82+ all = strings.Replace(all, `"sheets"`, `"active": 1, "sheets"`, 1)83+ r, err = ReadBook(strings.NewReader(all))84+ if err != nil {85+ t.Fatal(err)86+ }87+ if r.Sheet(0).Hidden() || !r.Sheet(1).Hidden() || r.Active() != 0 {88+ t.Errorf("every sheet hidden: first hidden %v, active %d", r.Sheet(0).Hidden(), r.Active())89+ }90+}
M internal/sheet/historysheets.go +10 -5

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/insertbook.go +117 -0

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/insertbook_test.go +112 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/load.go +8 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/nav.go +0 -40

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/occupancy.go +3 -2

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/recalc.go +15 -72

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/refs.go +20 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/remote.go +39 -3

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/remotekey.go +95 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/sheet.go +4 -3

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/sheet_test.go +10 -0

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/stats.go +153 -0

Not shown: this page already shows 6000 lines of diff.

A internal/sheet/stats_test.go +118 -0

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/store.go +12 -9

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/stress_test.go +3 -1

Not shown: this page already shows 6000 lines of diff.

M internal/sheet/workbook.go +17 -18

Not shown: this page already shows 6000 lines of diff.

M internal/ui/assist.go +23 -13

Not shown: this page already shows 6000 lines of diff.

A internal/ui/assistsheets.go +131 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/assistsheets_test.go +109 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/edit_test.go +13 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/entry.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/find.go +3 -3

Not shown: this page already shows 6000 lines of diff.

M internal/ui/findview.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/formula/formula.go +17 -4

Not shown: this page already shows 6000 lines of diff.

M internal/ui/formula/formula_test.go +7 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/hidden.go +113 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/hidden_test.go +144 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/importplace.go +121 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/importplace_test.go +117 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/jev.go +39 -9

Not shown: this page already shows 6000 lines of diff.

M internal/ui/jev_test.go +69 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/macrohostnav.go +30 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/macrorec.go +3 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/menubar.go +2 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/model.go +12 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/names.go +6 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/palette.go +33 -3

Not shown: this page already shows 6000 lines of diff.

M internal/ui/pivotactions.go +4 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/selection.go +6 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/serve_test.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/stress_test.go +10 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabs.go +27 -14

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabs_test.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabstrip.go +16 -5

Not shown: this page already shows 6000 lines of diff.

M internal/ui/trace.go +3 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer.go +28 -23

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer_test.go +6 -6

Not shown: this page already shows 6000 lines of diff.

M scripts/obs.sh +1 -1

Not shown: this page already shows 6000 lines of diff.