@lotics/xlsx 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,388 @@
1
+ # @lotics/xlsx — the spreadsheet (.xlsx) read / build / write reference
2
+
3
+ A self-contained spreadsheet engine: it parses `.xlsx` (and legacy `.xls`) into an immutable
4
+ model, builds workbooks from scratch or from rows, evaluates formulas, authors native pivot
5
+ tables, and writes real `.xlsx` bytes — with **no third-party spreadsheet library** in the
6
+ export path. This file is the "reach for what" guide; the **exact API** of any function is its
7
+ source, which ships with the package: read `node_modules/@lotics/xlsx/src/<module>.ts`.
8
+
9
+ - **The barrel is the stable surface.** `import { parseExcelBuffer, exportWorkbook } from "@lotics/xlsx"`.
10
+ Everything documented as "barrel" below comes from there.
11
+ - **Subpath imports** (`import { parseBiff8 } from "@lotics/xlsx/biff8_reader"`) reach internals
12
+ that are NOT in the stability contract — the formula engine, the legacy reader, the template
13
+ helper. They work and are documented here where they're the right tool, but they can change
14
+ shape between versions; prefer the barrel when it covers the task.
15
+ - **Never hand-roll xlsx XML or reach for `exceljs`/`xlsx`/SheetJS.** They are not dependencies.
16
+ If a primitive is missing, add it to this package — every consumer inherits it.
17
+
18
+ ## The two type families — read this first
19
+
20
+ The whole package is organized around two shapes. Pick the right one and everything else follows.
21
+
22
+ - **`ParsedSpreadsheet` — the immutable snapshot.** What `parseExcelBuffer` returns. A plain
23
+ read-only tree: `sheets[]` → `rows[]` → `cells[]`, plus merges, images, charts, tables, pivots,
24
+ conditional formats. **If you only READ a file, stay here** — never build a model you won't mutate.
25
+ - **`WorkbookModel` — the mutable model.** Sparse cell storage, a deduplicated `StyleRegistry`,
26
+ undo/redo commands. **Build this when you WRITE** — every builder (`buildDataWorkbook`,
27
+ `buildPivotTable`, the raw `SheetModel`) produces one, and `exportWorkbook` consumes one.
28
+ To edit an existing file, parse to a snapshot then `loadWorkbookFromSnapshot(parsed)`.
29
+
30
+ ```
31
+ bytes ─parseExcelBuffer→ ParsedSpreadsheet ─loadWorkbookFromSnapshot→ WorkbookModel ─exportWorkbook→ bytes
32
+ (read here) (mutate here)
33
+ ```
34
+
35
+ `exportWorkbook` takes a `WorkbookModel`, never a snapshot. A snapshot is a dead end for writing.
36
+
37
+ ## Reach by task
38
+
39
+ ### Read an `.xlsx` (or `.xls`) — `excel_parser.ts` (barrel)
40
+
41
+ `parseExcelBuffer(arrayBuffer, options?) → ParsedSpreadsheet`. **Synchronous.** It takes an
42
+ **`ArrayBuffer`**, not a Node `Buffer` — see the slicing gotcha below. It auto-detects an OLE2
43
+ container (legacy `.xls` or an encrypted `.xlsx`) and routes it to the BIFF8 reader; genuinely
44
+ encrypted files throw `PasswordProtectedError` (re-exported from the barrel for your catch site).
45
+
46
+ ```ts
47
+ import { readFileSync } from "node:fs";
48
+ import { parseExcelBuffer } from "@lotics/xlsx";
49
+
50
+ const buf = readFileSync("report.xlsx");
51
+ // Buffer is a view over a possibly-pooled ArrayBuffer — slice to its own bytes:
52
+ const wb = parseExcelBuffer(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
53
+
54
+ for (const sheet of wb.sheets) {
55
+ for (const row of sheet.rows) { // sparse: only populated rows, in order
56
+ for (const cell of row.cells) { // sparse: only populated cols, in order
57
+ cell.column; // 1-based column index
58
+ cell.value; // the DISPLAY string (number-formatted, e.g. "1.234,00")
59
+ cell.rawValue; // the raw number, when the cell is numeric (no reparse of `value`)
60
+ cell.typedValue; // lossless typed value string|number|boolean|null (round-trip source)
61
+ cell.formula; // formula text WITHOUT a leading "=", e.g. "SUM(A1:A5)"
62
+ cell.numFmtCode; // e.g. "#,##0.00", "dd/mm/yyyy"
63
+ cell.error; // "#DIV/0!" etc. for error cells
64
+ cell.content; // { type: "plain"|"rich_text"|"hyperlink", ... }
65
+ cell.style; // CellStyle (fonts, fill, borders, alignment)
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ - **`value` is the formatted display string; `rawValue`/`typedValue` are the machine values.**
72
+ Do arithmetic on `rawValue`/`typedValue`, never `parseFloat(cell.value)` — the display string
73
+ is locale/format-mangled.
74
+ - **Rows and cells are sparse and index-bearing.** `row.index` (1-based) and `cell.column`
75
+ (1-based) are the truth; do not assume `rows[0]` is row 1 or `cells[0]` is column A.
76
+ - **Merges, hidden state, extras:** `sheet.mergedCells` (`{startRow,startCol,endRow,endCol}`,
77
+ 1-based), `sheet.hidden` (hidden/veryHidden sheets are parsed, only tagged — data consumers can
78
+ still read them), `sheet.freezePane`, `sheet.charts`, `sheet.tables`, `sheet.pivotTables`,
79
+ `sheet.conditionalFormats`, `sheet.images`, `sheet.dataValidations`. Workbook-scope:
80
+ `wb.namedRanges`, `wb.pivotCaches`, `wb.fullCalcOnLoad`.
81
+ - **Row cap.** `options.maxRowsPerSheet` defaults to **10 000** (a renderer guard). For full data
82
+ extraction pass `{ maxRowsPerSheet: Infinity }` and check `sheet.truncated` to see if the cap bit.
83
+ - `parseExcelFromZip(zip, options?)` (barrel) parses from an already-unzipped
84
+ `Record<string, Uint8Array>` (from `unzipXlsx`, also barrel). Use it when you need BOTH the
85
+ snapshot AND the raw ZIP entries — pass that same `zip` back to `exportWorkbook` as the
86
+ `originalZip` to preserve unmodified parts (see Export).
87
+
88
+ ### Legacy `.xls` (BIFF8) — `biff8_reader.ts` (subpath)
89
+
90
+ The modern parser reads only zip-based OOXML `.xlsx`. A pre-2007 `.xls` is an OLE2 Compound File
91
+ of BIFF records — a completely different container. **`parseExcelBuffer` handles this for you:** it
92
+ detects the OLE2 magic and routes to `parseBiff8`, returning the same `ParsedSpreadsheet` shape, so
93
+ most callers never touch this module. Reach for it directly only when you already hold the bytes as
94
+ a `Uint8Array` and know it's a legacy workbook:
95
+
96
+ ```ts
97
+ import { parseBiff8 } from "@lotics/xlsx/biff8_reader";
98
+ const parsed = parseBiff8(new Uint8Array(bytes), { maxRowsPerSheet: Infinity });
99
+ ```
100
+
101
+ `parseBiff8(bytes: Uint8Array, options?: ParseOptions) → ParsedSpreadsheet`. **Scope is the data
102
+ grid**, so consumers get uniform cell values with no per-caller `.xls` handling:
103
+
104
+ - Reads: shared strings (`SST`, including `CONTINUE`-split records), string cells (`LABELSST`,
105
+ `LABEL`), numbers (`NUMBER`, `RK`, `MULRK`), boolean/numeric cached formula results, multi-sheet
106
+ workbooks (`BOUNDSHEET`), the 1900/1904 date mode (`DATEMODE`), and number-format codes
107
+ (`FORMAT`/`XF`) — enough to detect dates and currency so serials render correctly downstream.
108
+ - **Does NOT surface:** cell styling (returns `style: {}`), merged cells, charts, images, column
109
+ widths, row heights, or hidden state. Blank/`MULBLANK`/`BOOLERR` cells are skipped. A formula
110
+ whose cached result is a **string** or **error** yields nothing (the resolving `STRING` record is
111
+ out of scope) rather than a wrong value.
112
+ - Encrypted `.xls` (a `FILEPASS` record or an OLE2 `EncryptedPackage` stream) throws
113
+ `PasswordProtectedError`. A non-BIFF8 OLE2 container throws a plain descriptive error.
114
+ - No third-party dependency and no regex/eval over file bytes — this path replaced SheetJS, which
115
+ was removed for prototype-pollution + ReDoS CVEs. Do not reintroduce SheetJS.
116
+
117
+ ### Build a sheet from rows — `data_workbook.ts` (barrel)
118
+
119
+ The common "I have records, give me a styled `.xlsx`" path. Declare each column once with a `type`
120
+ and a `value` accessor; the helper writes a bold frozen header, per-type number formats (dates as
121
+ real Excel serials), right-aligned numerics, and auto-fit widths.
122
+
123
+ ```ts
124
+ import { buildDataWorkbook, exportWorkbook } from "@lotics/xlsx";
125
+
126
+ const wb = buildDataWorkbook({
127
+ sheetName: "Phí", // sanitized to Excel's rules, ≤31 chars
128
+ currencyFormat: '#,##0" ₫"', // default "#,##0"
129
+ dateFormat: "dd/mm/yyyy", // default "dd/mm/yyyy"
130
+ columns: [
131
+ { header: "Container", value: (r) => r.container }, // type defaults to "text"
132
+ { header: "Ngày", type: "date", value: (r) => r.date }, // Date | "YYYY-MM-DD"
133
+ { header: "Số tiền", type: "currency", value: (r) => r.amount },// number | numeric string
134
+ ],
135
+ rows,
136
+ });
137
+ const bytes = exportWorkbook(wb);
138
+ ```
139
+
140
+ - `DataColumnType` = `"text" | "integer" | "number" | "currency" | "date"`. `integer`→`#,##0`,
141
+ `number`→`#,##0.##`, `currency`→`currencyFormat`, `date`→`dateFormat`; numerics right-align.
142
+ - A `date` accessor returns a `Date` or an ISO `YYYY-MM-DD` string → stored as an Excel serial.
143
+ An **unparseable** date/number is written as its text so nothing is silently dropped.
144
+ - `value` returning `null`/`undefined`/`""` leaves the cell truly empty. Column `width` (in
145
+ characters) overrides the auto-fit; auto-fit samples the first 1000 rows.
146
+ - **Multi-sheet:** `buildDataSheet(opts, styles)` builds ONE `SheetModel` into a caller-supplied
147
+ `StyleRegistry`. Every sheet in one workbook must share the SAME registry (styles are stored by
148
+ index into it) — build each with `buildDataSheet` against one `new StyleRegistry()`, then
149
+ `new WorkbookModel({ sheets, activeSheetIndex: 0, styles })`.
150
+
151
+ ### Build cell-by-cell — `workbook_model.ts` (barrel)
152
+
153
+ When the layout isn't a flat table (forms, invoices, dashboards), drive `SheetModel` directly.
154
+
155
+ ```ts
156
+ import { WorkbookModel, SheetModel, StyleRegistry, exportWorkbook } from "@lotics/xlsx";
157
+
158
+ const styles = new StyleRegistry();
159
+ const sheet = new SheetModel("Invoice", styles);
160
+
161
+ sheet.set("A1", "Total", { fontBold: true }); // set(ref, value, style?, numFmt?, formula?)
162
+ sheet.set("B1", 1234.5, undefined, "#,##0.00"); // value + number-format code
163
+ sheet.merge("A1:C1", "Header", { horizontalAlign: "center" });
164
+ sheet.setColumnWidth("A", 24); // letter or 0-based index
165
+ sheet.setRowHeight(1, 22); // row is 1-based
166
+ sheet.setFreeze("A2"); // freeze row 1
167
+ sheet.headerRow(1, ["A", "B", "C"], { background: "#D9E1F2" }); // bold+centered+bordered row
168
+
169
+ const wb = new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
170
+ const bytes = exportWorkbook(wb);
171
+ ```
172
+
173
+ - `set(ref, value, style?, numFmt?, formula?) → CellModel`. `value` is `string | number | boolean
174
+ | null`. `style` is a `CellStyle` (interned via the registry). `numFmt` is a format-code STRING
175
+ (`"#,##0.00"`, `"dd/mm/yyyy"`, `"General"`). `formula` is the formula text **WITHOUT `=`** (see
176
+ Formulas).
177
+ - `SheetModel` also exposes `getCell`, `deleteCell`, `colWidths`/`rowHeights` maps,
178
+ `hiddenRows`/`hiddenCols` sets, `mergedCells`, `getUsedRange()`, and `view`
179
+ (`showGridLines`, `zoomScale`, `tabColor`, …).
180
+ - `setWithStyleIndex(ref, value, styleIndex, numFmtCode?, formula?, originalXfIndex?)` is the hot
181
+ path for bulk fills: it skips the display-value/content materialization. If you later need
182
+ `cell.displayValue` for such a cell, call `materializeDisplayValue(cell)` (barrel).
183
+ - **Edit a parsed file:** `loadWorkbookFromSnapshot(parsed, date1904?)` turns a `ParsedSpreadsheet`
184
+ into a `WorkbookModel`, interning every style and marking the registry clean so a value-only edit
185
+ keeps the export fast path (see Export).
186
+ - **`buildTemplateWorkbook(...)`** (subpath `@lotics/xlsx/template_builder`) is sugar over the above
187
+ for one or several sheets: `buildTemplateWorkbook((s) => {...})` or
188
+ `buildTemplateWorkbook({ name, build }, ...)`.
189
+
190
+ ### Styling — `types.ts` (`CellStyle`) + `style_helpers.ts` + `ooxml_color.ts` (barrel)
191
+
192
+ A `CellStyle` is a flat options bag interned by the `StyleRegistry` (Excel's `cellXfs` model — one
193
+ stored entry per distinct style). Every field is optional:
194
+
195
+ - **Font:** `fontBold`, `fontItalic`, `fontStrike`, `fontColor` (`#RRGGBB`), `fontSize` (points),
196
+ `fontName`, `fontUnderline` (`"single"|"double"`), `fontVertAlign` (`"superscript"|"subscript"`).
197
+ - **Fill:** `backgroundColor` (`#RRGGBB`), `patternType`, `gradientData` (`{type, degree, stops}`).
198
+ - **Alignment:** `horizontalAlign` (`"left"|"center"|"right"`), `verticalAlign`
199
+ (`"top"|"middle"|"bottom"`), `wrapText`, `shrinkToFit`, `indent`, `textRotation`.
200
+ - **Borders:** `borderTop`/`borderRight`/`borderBottom`/`borderLeft`/`borderDiagonal`, each a
201
+ `BorderStyle` = `{ width, style, color? }` where `style` is an OOXML border style name
202
+ (`"thin"`, `"medium"`, `"thick"`, `"dashed"`, `"double"`, …).
203
+
204
+ Reach for the shortcuts instead of hand-writing four border objects:
205
+
206
+ - `allBorders(style?="thin", color?="#000000")` → all four sides; `topBorder(...)` /
207
+ `bottomBorder(style?="medium", ...)` → one side. Spread into a `CellStyle`.
208
+ - Colors: `normalizeColor`, `prefixHex`, `hexToArgb`, `hexToOoxmlRgb`, `applyTint` — canonical
209
+ `#RRGGBB` in the model; the writer converts to OOXML `AARRGGBB` on export.
210
+
211
+ ### Formulas — `set(...)`'s 5th arg + `formula_engine.ts` (subpath)
212
+
213
+ Two facts govern everything here:
214
+
215
+ 1. **A cell carries formula text AND a cached value.** `sheet.set(ref, cachedValue, style, numFmt,
216
+ formulaText)`. `exportWorkbook` writes both: `<f>formulaText</f>` and `<v>cachedValue</v>`.
217
+ 2. **The writer does NOT recompute.** It also stamps `<calcPr fullCalcOnLoad="1"/>` on the workbook,
218
+ so **Excel/LibreOffice recompute every formula the moment they open your file** — a formula
219
+ written with `null` as its cached value still shows the right number in a spreadsheet app.
220
+
221
+ ```ts
222
+ sheet.set("D2", 15, undefined, undefined, "SUM(A2:C2)"); // formula text has NO leading "="
223
+ sheet.set("D3", null, undefined, undefined, "SUM(A3:C3)"); // cached value unknown → Excel fills it
224
+ ```
225
+
226
+ The gap: a **non-Excel** consumer that reads the cached `<v>` (this package's own parser/renderer, a
227
+ PDF/print pipeline, another tool) sees `null`/stale until it recomputes. To fill correct cached
228
+ values yourself before export:
229
+
230
+ ```ts
231
+ import { recomputeAll } from "@lotics/xlsx/formula_engine";
232
+ recomputeAll(workbook); // evaluates every formula cell, overwriting cached values
233
+ const bytes = exportWorkbook(workbook);
234
+ ```
235
+
236
+ - `recomputeAll(workbook)` and `recomputeIfStale(workbook)` (the latter only recomputes when the
237
+ workbook declares `fullCalcOnLoad`) are the convenience entry points. For incremental recalc while
238
+ editing, construct a `FormulaEngine(workbook)` and call `buildGraph()` + `evaluateMissing()` /
239
+ `evaluateAll()`, or `onCellChanged(sheet, ref, formula)` after each edit.
240
+ - **Unsupported functions:** `LAMBDA`, `LET`, dynamic-array spill, and cube functions are not
241
+ implemented (no open-source engine covers them) — don't emit workbooks that depend on them and
242
+ expect this package to evaluate the result. Excel still will, on open.
243
+ - **When you set a cell from a raw user/agent string that may or may not be a formula**, use the
244
+ command layer instead: `setCellValueCommand` treats a leading `=` as a formula and strips it for
245
+ you (see Editing). The raw `set()`/`setWithStyleIndex()` path expects the `=` already removed.
246
+
247
+ ### Native pivot tables — `pivot_builder.ts` (barrel)
248
+
249
+ `buildPivotTable(workbook, spec) → PivotTableModel` authors a **real, refreshable Excel
250
+ PivotTable** (not a pre-rendered cross-tab): `exportWorkbook` serializes it into genuine
251
+ `pivotCacheDefinition` / `pivotCacheRecords` / `pivotTable` OOXML parts, so the user can refresh and
252
+ re-pivot it in Excel.
253
+
254
+ **v1 shape — the 2D cross-tab:** exactly one row field, one column field, one data (value) field,
255
+ and zero or more page (report-filter) fields.
256
+
257
+ ```ts
258
+ import { buildPivotTable } from "@lotics/xlsx";
259
+
260
+ buildPivotTable(workbook, {
261
+ name: "PivotByPort", // unique within the workbook
262
+ source: { sheetName: "Data", ref: "A1:E500" },// first row is the header
263
+ rowField: 0, // 0-based column index WITHIN the range
264
+ colField: 2,
265
+ data: { field: 4, fn: "sum", numFmtId: "3" }, // fn defaults to "sum"
266
+ pageFields: [1], // optional report filters
267
+ location: { sheetName: "Pivot", anchor: "A3" },
268
+ });
269
+ ```
270
+
271
+ - `data.fn` is a `PivotAggregateFn`: `"sum" | "count" | "countNums" | "average" | "min" | "max" |
272
+ "product"`. `rowGrandTotals`/`colGrandTotals` default `true`; `styleName` defaults
273
+ `"PivotStyleLight16"`.
274
+ - **`data.numFmtId` is a BUILT-IN Excel number-format ID** (e.g. `"3"` = `#,##0`), a string, NOT a
275
+ format code. A custom code like `'#,##0" ₫"'` needs a registered style, which v1 does not wire up.
276
+ - Validation throws (not silently) on an out-of-range field index or a field reused in more than one
277
+ role (row/column/data/page). The returned model is already attached to the target sheet; just
278
+ export.
279
+ - The read side of pivots (`ParsedPivotTable`, `ParsedPivotCache`, `PivotTableModel`) is barrel-
280
+ exported for inspecting pivots parsed out of an existing file.
281
+
282
+ ### Tables, charts, sparklines — authored via the parsed structures (barrel types)
283
+
284
+ There is **no dedicated `buildTable`/`buildChart` helper.** These features are read-first: the
285
+ parser fills `sheet.tables` (`ParsedTable`), `sheet.charts` (`ParsedChart`), `sheet.sparklineGroups`,
286
+ etc., and they **round-trip through `exportWorkbook` unchanged.** To AUTHOR one, construct the parsed
287
+ structure and push it onto the sheet array on the `SheetModel`; the writer serializes
288
+ `sheet.tables` → `xl/tables/*.xml` and `sheet.charts` → `xl/charts/*.xml` + drawing anchors.
289
+
290
+ ```ts
291
+ sheet.tables.push({
292
+ name: "tblFees", displayName: "tblFees", ref: "A1:D20",
293
+ headerRow: true, totalsRow: false, autoFilter: true, styleName: "TableStyleMedium2",
294
+ columns: [ { id: 1, name: "Container" }, { id: 2, name: "Amount" }, /* … match the range */ ],
295
+ });
296
+ ```
297
+
298
+ A `ParsedChart` needs `chartType` (`"bar"|"col"|"line"|"pie"|"area"|"scatter"|…`), `series[]`
299
+ (`{ name?, values: number[], color? }`), optional `categories`/`axes`/`legendPosition`, and an
300
+ `anchor` (`{ from:{row,col}, to:{row,col} }`, 0-based). Treat this as a lower-level path than
301
+ `buildPivotTable`: you're assembling the read-model by hand, so match the column set to the range and
302
+ verify the output opens in Excel.
303
+
304
+ ### Edit with undo/redo — `spreadsheet_commands.ts` (barrel)
305
+
306
+ Every mutation that needs to be undoable flows through a command factory that captures inverse state.
307
+ Each returns a `Command` (`{ description, execute, undo }`); run it and push it onto your own history
308
+ (or `command_history.ts`). The full set is barrel-exported: `setCellValueCommand`,
309
+ `setCellStyleCommand`, `setRangeStyleCommand`, `setRangeNumFmtCommand`, `deleteCellCommand`,
310
+ `clearRangeCommand`, `mergeCellsCommand`/`unmergeCellsCommand`, `insertRowsCommand`/
311
+ `deleteRowsCommand`, `insertColsCommand`/`deleteColsCommand`, `setColWidthCommand`/
312
+ `setRowHeightCommand`, `setNamedRangeCommand`, `addSheetCommand`/`deleteSheetCommand`/
313
+ `renameSheetCommand`/`reorderSheetsCommand`, `setHiddenRowsCommand`/`setHiddenColsCommand`,
314
+ `sortColumnCommand`/`multiSortCommand`, `setFreezePanesCommand`, `setZoomCommand`,
315
+ `addConditionalFormatCommand`/`deleteConditionalFormatCommand`, `insertCellsShiftDownCommand`, and
316
+ `batchCommand` (compose several into one undo step).
317
+
318
+ - `setCellValueCommand(workbook, engine, sheetIndex, row, col, newValue)` — `newValue` is the raw
319
+ string; a leading `=` is detected and stored as a formula (with `null` cached value for the
320
+ `engine` to compute). Pass a `FormulaEngine` so dependents recalc; pass `undefined` to skip recalc.
321
+ - Row/col insert/delete re-key populated cells and shift formula references (via
322
+ `adjustMergedCells*` / `expandFormulaRangesForRowInsert`, also barrel-exported) — this is why they
323
+ must be commands, not direct `SheetModel` edits.
324
+ - Row/col insert/delete also **clamp the used range**: before shifting, they drop "phantom" cells —
325
+ value-less, unstyled, General-format padding outside the bounding box of the cells that carry data
326
+ or formatting. Real files accumulate these (a bloated stored `<dimension>` or a stray fill leaves
327
+ empty cells far past the data), and left in place they inflate the used range that drives the canvas
328
+ render width and the exported `<dimension>`. Undo restores them, and cells with any value, formula,
329
+ style, number format, or hyperlink are never dropped.
330
+
331
+ ### Export to `.xlsx` bytes — `xlsx_writer.ts` (barrel)
332
+
333
+ `exportWorkbook(workbook, originalZip?) → Uint8Array`. **Synchronous.** Returns a fresh
334
+ zip (`fflate` level 6). Write it to disk:
335
+
336
+ ```ts
337
+ import { writeFileSync } from "node:fs";
338
+ import { exportWorkbook } from "@lotics/xlsx";
339
+
340
+ const bytes = exportWorkbook(wb); // Uint8Array
341
+ writeFileSync("out.xlsx", Buffer.from(bytes)); // Buffer.from wraps the bytes for fs
342
+ ```
343
+
344
+ - **`originalZip` preserves unmodified parts.** When you parsed with `parseExcelFromZip(zip)` and
345
+ edited the resulting model, pass that same `zip` back: `exportWorkbook(wb, zip)`. Parts the writer
346
+ regenerates (worksheets, shared strings, workbook, styles) are rebuilt; everything else
347
+ (drawings, media, themes, VBA) passes through byte-for-byte.
348
+ - **Styles-passthrough fast path.** If `originalZip` is supplied AND no style was mutated
349
+ (`workbook.styles.dirty === false`, which `loadWorkbookFromSnapshot` sets up) AND the original had
350
+ `xl/styles.xml`, the writer reuses cells' original `s=` indices and skips rebuilding styles
351
+ entirely — the dominant path for value-only template fills. Touching any style (interning a new
352
+ `CellStyle` or a new numFmt code) flips `dirty` and takes the full rebuild.
353
+
354
+ ## Gotchas
355
+
356
+ - **`parseExcelBuffer` takes an `ArrayBuffer`, not a Node `Buffer`.** A `Buffer` is a view over a
357
+ possibly-shared/pooled `ArrayBuffer`, so `buf.buffer` may contain other files' bytes. Always slice
358
+ to the view's own window: `buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)`.
359
+ Passing `buf.buffer` directly can hand the parser garbage tail bytes.
360
+ - **Formula text has NO leading `=` on the model path.** `sheet.set(ref, v, s, fmt, "SUM(A1:A5)")`,
361
+ not `"=SUM(...)"`. The barrel `set`/`setWithStyleIndex` write the string verbatim into `<f>`, and
362
+ OOXML formulas omit the `=`. The ONLY place a `=` is expected is `setCellValueCommand`, which
363
+ parses raw user input and strips it.
364
+ - **The writer never recomputes; it relies on `fullCalcOnLoad`.** A formula written with a `null` or
365
+ stale cached value is correct in Excel (which recalcs on open) but wrong to any consumer that
366
+ trusts the cached `<v>` without recomputing. Call `recomputeAll(workbook)` before export when a
367
+ non-Excel reader will consume the file.
368
+ - **Multi-sheet workbooks must share ONE `StyleRegistry`.** Cell styles are stored by index into a
369
+ single registry; sheets built against different registries render with each other's styles when
370
+ merged. Use `buildDataSheet(opts, styles)` / `new SheetModel(name, styles)` with one shared
371
+ registry, never `buildDataWorkbook` per sheet.
372
+ - **Dates are Excel serial numbers plus a date number-format code, not `Date` objects.**
373
+ `buildDataWorkbook`'s `date` columns convert for you (from a `Date` via LOCAL `YYYY-MM-DD`, or from
374
+ an ISO string). On the raw model path, store the serial and set a date `numFmt`
375
+ (`parseToExcelSerial(iso)` from `@lotics/xlsx/numfmt_type` computes one; it interprets the ISO date
376
+ in UTC and anchors at 1899-12-30 for the Lotus-1900-leap-year bug).
377
+ - **`maxRowsPerSheet` defaults to 10 000 and silently truncates.** For a full extract pass
378
+ `{ maxRowsPerSheet: Infinity }` and check `sheet.truncated`.
379
+ - **`cell.value` is the formatted display string.** Compute on `cell.rawValue`/`cell.typedValue`;
380
+ never `parseFloat(cell.value)`.
381
+ - **Legacy `.xls` is data-only.** No styles, merges, widths, charts, or images survive a `.xls`
382
+ read, and string/error formula results are dropped. If a downstream step needs those, it must come
383
+ from an `.xlsx`.
384
+ - **Pivot `data.numFmtId` is a built-in numeric ID string, not a format code** — a custom currency
385
+ code won't apply in v1.
386
+ - **`setWithStyleIndex` leaves `displayValue` empty** (it's the export fast path). If you read
387
+ `cell.displayValue` off such a cell before exporting, it's `""` until you call
388
+ `materializeDisplayValue(cell)`.
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@lotics/xlsx",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
7
7
  "./*": "./src/*.ts"
8
8
  },
9
9
  "files": [
10
- "src"
10
+ "src",
11
+ "AGENTS.md"
11
12
  ],
12
13
  "publishConfig": {
13
14
  "access": "public"
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { parseBiff8 } from "./biff8_reader";
5
+ import { loadWorkbookFromSnapshot } from "./workbook_model";
5
6
  import { isOle2, PasswordProtectedError } from "@lotics/office-io";
6
7
 
7
8
  // A synthetic BIFF8 (.xls) workbook: 250 data rows of Unicode strings + integer,
@@ -28,30 +29,42 @@ describe("parseBiff8", () => {
28
29
 
29
30
  it("decodes Unicode text cells", () => {
30
31
  const s = parseBiff8(fixture).sheets[0];
31
- expect(cell(s, 0, 0)).toBe("ID");
32
- expect(cell(s, 0, 1)).toBe("Tên khách");
33
- expect(cell(s, 0, 2)).toBe("Số tiền");
32
+ expect(cell(s, 1, 1)).toBe("ID");
33
+ expect(cell(s, 1, 2)).toBe("Tên khách");
34
+ expect(cell(s, 1, 3)).toBe("Số tiền");
34
35
  });
35
36
 
36
37
  it("decodes a string that falls past the SST CONTINUE boundary", () => {
37
38
  const s = parseBiff8(fixture).sheets[0];
38
- expect(cell(s, 200, 1)).toBe("Khách 200 — Công ty TNHH số 200");
39
- expect(cell(s, 249, 1)).toBe("Khách 249 — Công ty TNHH số 249");
39
+ expect(cell(s, 201, 2)).toBe("Khách 200 — Công ty TNHH số 200");
40
+ expect(cell(s, 250, 2)).toBe("Khách 249 — Công ty TNHH số 249");
40
41
  });
41
42
 
42
43
  it("decodes integer, float and negative numbers to the exact value", () => {
43
44
  const s = parseBiff8(fixture).sheets[0];
44
- expect(cell(s, 2, 2)).toBe(2000); // integer (RK int)
45
- expect(cell(s, 3, 2)).toBe(3000.5); // float
46
- expect(cell(s, 5, 2)).toBe(-500); // negative
47
- expect(cell(s, 200, 2)).toBe(-20000);
48
- expect(cell(s, 249, 2)).toBe(249000.5);
45
+ expect(cell(s, 3, 3)).toBe(2000); // integer (RK int)
46
+ expect(cell(s, 4, 3)).toBe(3000.5); // float
47
+ expect(cell(s, 6, 3)).toBe(-500); // negative
48
+ expect(cell(s, 201, 3)).toBe(-20000);
49
+ expect(cell(s, 250, 3)).toBe(249000.5);
49
50
  });
50
51
 
51
52
  it("honors maxRowsPerSheet and flags truncation", () => {
52
53
  const s = parseBiff8(fixture, { maxRowsPerSheet: 10 }).sheets[0];
53
54
  expect(s.truncated).toBe(true);
54
- expect(s.rows.every((r) => r.index < 10)).toBe(true);
55
+ expect(s.rows.every((r) => r.index <= 10)).toBe(true);
56
+ });
57
+
58
+ it("emits the ParsedSpreadsheet 1-based convention: A1 survives model load and export refs", () => {
59
+ // The regression that motivated normalization: 0-based BIFF indices fed
60
+ // loadWorkbookFromSnapshot's 1-based A1-ref construction, mapping the whole
61
+ // first column to invalid refs (colNumToLetters(0) === ""). The outcome
62
+ // that matters: the .xls's first cell lands at A1 in the model.
63
+ const wb = loadWorkbookFromSnapshot(parseBiff8(fixture));
64
+ const sheet = wb.sheets[0];
65
+ expect(sheet.cells.get("A1")?.value).toBe("ID");
66
+ expect(sheet.cells.get("B1")?.value).toBe("Tên khách");
67
+ expect(sheet.cells.has("0")).toBe(false);
55
68
  });
56
69
 
57
70
  it("rejects an OLE2 buffer with no Workbook stream", () => {
@@ -98,19 +111,19 @@ describe("parseBiff8 number formats", () => {
98
111
  };
99
112
 
100
113
  it("reads a date cell as an Excel serial carrying its date number-format", () => {
101
- const c = cell(1, 0);
114
+ const c = cell(2, 1);
102
115
  expect(c?.typedValue).toBe(45672); // 2025-01-15
103
116
  expect(c?.numFmtCode).toBe("dd/mm/yyyy");
104
117
  });
105
118
 
106
119
  it("leaves a plain number with no number-format (General)", () => {
107
- expect(cell(1, 1)?.typedValue).toBe(42);
108
- expect(cell(1, 1)?.numFmtCode).toBeUndefined();
109
- expect(cell(1, 2)?.typedValue).toBe(1234.5);
120
+ expect(cell(2, 2)?.typedValue).toBe(42);
121
+ expect(cell(2, 2)?.numFmtCode).toBeUndefined();
122
+ expect(cell(2, 3)?.typedValue).toBe(1234.5);
110
123
  });
111
124
 
112
125
  it("decodes a Unicode string cell", () => {
113
- expect(cell(1, 3)?.typedValue).toBe("Ábc");
126
+ expect(cell(2, 4)?.typedValue).toBe("Ábc");
114
127
  });
115
128
  });
116
129
 
@@ -237,7 +237,7 @@ function parseSheet(stream: Uint8Array, start: number, name: string, g: Globals,
237
237
  const r = grid.get(ri);
238
238
  if (!r) continue;
239
239
  const cells = [...r.values()].sort((a, b) => a.column - b.column);
240
- rows.push({ index: ri, height: sheet.defaultRowHeight, cells, hidden: false });
240
+ rows.push({ index: ri + 1, height: sheet.defaultRowHeight, cells, hidden: false });
241
241
  }
242
242
  sheet.rows = rows;
243
243
  sheet.totalRowCount = maxRow + 1;
@@ -247,14 +247,19 @@ function parseSheet(stream: Uint8Array, start: number, name: string, g: Globals,
247
247
 
248
248
  // ─── Cell builders ──────────────────────────────────────────────────────────
249
249
 
250
+ // BIFF stores 0-based row/col on disk; ParsedSpreadsheet is 1-based (the OOXML
251
+ // parser's convention, and what loadWorkbookFromSnapshot's A1-ref construction
252
+ // requires — colNumToLetters(0) is "", so a 0-based column would silently land
253
+ // every first-column cell at an invalid ref). Builders receive the raw BIFF
254
+ // column and normalize at this single escape point.
250
255
  function textCell(column: number, text: string): ParsedCell {
251
- return { column, value: text, typedValue: text, content: { type: "plain", text }, style: {} };
256
+ return { column: column + 1, value: text, typedValue: text, content: { type: "plain", text }, style: {} };
252
257
  }
253
258
 
254
259
  function numberCell(column: number, num: number, numFmt: string | undefined): ParsedCell {
255
260
  const value = Number.isFinite(num) ? String(num) : "";
256
261
  return {
257
- column, value, rawValue: num, typedValue: num,
262
+ column: column + 1, value, rawValue: num, typedValue: num,
258
263
  numFmtCode: numFmt && numFmt !== "General" ? numFmt : undefined,
259
264
  content: { type: "plain", text: value }, style: {},
260
265
  };
@@ -268,7 +273,7 @@ function formulaCell(column: number, body: Uint8Array, numFmt: string | undefine
268
273
  const kind = body[6];
269
274
  if (kind === 1) {
270
275
  const b = body[8] !== 0;
271
- return { column, value: b ? "TRUE" : "FALSE", typedValue: b, content: { type: "plain", text: b ? "TRUE" : "FALSE" }, style: {} };
276
+ return { column: column + 1, value: b ? "TRUE" : "FALSE", typedValue: b, content: { type: "plain", text: b ? "TRUE" : "FALSE" }, style: {} };
272
277
  }
273
278
  // string (resolved by a following STRING record — none in scope), error, or
274
279
  // empty: surface nothing rather than a wrong value.
@@ -27,10 +27,15 @@ import {
27
27
  setShowGridLinesCommand,
28
28
  setShowHeadersCommand,
29
29
  setHyperlinkCommand,
30
+ insertRowsCommand,
31
+ deleteRowsCommand,
32
+ insertColsCommand,
33
+ deleteColsCommand,
30
34
  } from "./spreadsheet_commands";
31
35
  import { CommandHistory } from "./command_history";
32
36
  import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
33
37
  import type { ChangeEvent } from "./workbook_model";
38
+ import { rowColToRef, refToRowCol } from "./excel_utils";
34
39
 
35
40
  function makeWorkbook(): WorkbookModel {
36
41
  const styles = new StyleRegistry();
@@ -770,3 +775,107 @@ describe("setHyperlinkCommand", () => {
770
775
  expect(wb.sheets[0].hyperlinks.has("A1")).toBe(false);
771
776
  });
772
777
  });
778
+
779
+ describe("structural ops clamp phantom cells", () => {
780
+ // A sheet whose real data ends at column C but that carries value-less,
781
+ // unstyled "phantom" cells out to column 40 in row 2 — the shape a bloated
782
+ // stored `<dimension>` (e.g. A1:XDD63) leaves behind. Left alone, these
783
+ // inflate the used range, driving the render width and exported dimension to
784
+ // the phantom extent.
785
+ function makeSheetWithJunkWidth(): WorkbookModel {
786
+ const wb = makeWorkbook();
787
+ const sheet = wb.sheets[0];
788
+ sheet.set("A1", "Name");
789
+ sheet.set("B1", "Qty");
790
+ sheet.set("C1", "Price");
791
+ sheet.set("A2", "Widget");
792
+ sheet.set("B2", 5);
793
+ sheet.set("C2", 10);
794
+ for (let c = 4; c <= 40; c++) sheet.set(rowColToRef(2, c), null);
795
+ return wb;
796
+ }
797
+
798
+ test("insert-rows drops phantom cells beyond the used-data columns", () => {
799
+ const wb = makeSheetWithJunkWidth();
800
+ const sheet = wb.sheets[0];
801
+ expect(sheet.getUsedRange().maxCol).toBe(40); // phantoms inflate it pre-op
802
+
803
+ insertRowsCommand(wb, 0, 2, 1).execute();
804
+
805
+ // Used range is clamped to the real data extent (column C = 3).
806
+ expect(sheet.getUsedRange().maxCol).toBe(3);
807
+ // No cell — in the inserted row or any other — exists beyond column C.
808
+ for (const ref of sheet.cells.keys()) {
809
+ expect(refToRowCol(ref)!.col).toBeLessThanOrEqual(3);
810
+ }
811
+ // Data is intact and shifted: row 2 is now blank, data moved to row 3.
812
+ expect(sheet.getCell("A3")?.value).toBe("Widget");
813
+ });
814
+
815
+ test("preserves an empty but styled cell — not a phantom", () => {
816
+ // A styles-wired sheet, as loaded from a file, so `set` can intern a style.
817
+ const styles = new StyleRegistry();
818
+ const sheet = new SheetModel("Sheet1", styles);
819
+ const wb = new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
820
+ sheet.set("A1", "data");
821
+ sheet.set("E1", null, { fontBold: true }); // empty yet meaningfully styled
822
+ expect(sheet.getCell("E1")?.styleIndex).not.toBe(0);
823
+
824
+ insertRowsCommand(wb, 0, 1, 1).execute();
825
+
826
+ // The styled empty cell survives (shifted to E2) and still extends the range.
827
+ expect(sheet.getUsedRange().maxCol).toBe(5);
828
+ expect(sheet.getCell("E2")?.styleIndex).not.toBe(0);
829
+ });
830
+
831
+ test("leaves phantom cells that sit inside the data bounding box", () => {
832
+ const wb = makeWorkbook();
833
+ const sheet = wb.sheets[0];
834
+ sheet.set("A1", "x");
835
+ sheet.set("C1", "y"); // data spans A1:C1
836
+ sheet.set("B1", null); // a phantom INSIDE the box — must not be dropped
837
+
838
+ insertRowsCommand(wb, 0, 1, 1).execute();
839
+
840
+ // B shifts to B2 and remains present; the box is unchanged.
841
+ expect(sheet.getCell("B2")).toBeDefined();
842
+ expect(sheet.getUsedRange().maxCol).toBe(3);
843
+ });
844
+
845
+ test("delete-rows also clamps phantom cells", () => {
846
+ const wb = makeSheetWithJunkWidth();
847
+ const sheet = wb.sheets[0];
848
+ deleteRowsCommand(wb, 0, 1, 1).execute();
849
+ expect(sheet.getUsedRange().maxCol).toBe(3);
850
+ });
851
+
852
+ test("insert-cols also clamps phantom cells", () => {
853
+ const wb = makeSheetWithJunkWidth();
854
+ const sheet = wb.sheets[0];
855
+ insertColsCommand(wb, 0, 1, 1).execute();
856
+ // Phantoms pruned; real data A:C shifted right to B:D.
857
+ expect(sheet.getUsedRange().maxCol).toBe(4);
858
+ });
859
+
860
+ test("delete-cols also clamps phantom cells", () => {
861
+ const wb = makeSheetWithJunkWidth();
862
+ const sheet = wb.sheets[0];
863
+ deleteColsCommand(wb, 0, 1, 1).execute();
864
+ // Phantoms pruned; B:C shifted left to A:B.
865
+ expect(sheet.getUsedRange().maxCol).toBe(2);
866
+ });
867
+
868
+ test("undo restores the pruned phantom cells", () => {
869
+ const wb = makeSheetWithJunkWidth();
870
+ const sheet = wb.sheets[0];
871
+ const beforeSize = sheet.cells.size;
872
+
873
+ const cmd = insertRowsCommand(wb, 0, 2, 1);
874
+ cmd.execute();
875
+ expect(sheet.getUsedRange().maxCol).toBe(3);
876
+
877
+ cmd.undo();
878
+ expect(sheet.cells.size).toBe(beforeSize);
879
+ expect(sheet.getUsedRange().maxCol).toBe(40);
880
+ });
881
+ });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { Command } from "./command_history";
11
11
  import { SheetModel } from "./workbook_model";
12
- import type { WorkbookModel, CellValue } from "./workbook_model";
12
+ import type { WorkbookModel, CellValue, CellModel } from "./workbook_model";
13
13
  import type { CellStyle, CellContent } from "./types";
14
14
  import type { ParsedConditionalFormat } from "./excel_cf_types";
15
15
  import type { FormulaEngine } from "./formula_engine";
@@ -319,6 +319,90 @@ export function unmergeCellsCommand(
319
319
  };
320
320
  }
321
321
 
322
+ // =============================================================================
323
+ // Phantom-cell clamping for structural row/column ops
324
+ // =============================================================================
325
+ //
326
+ // Real-world .xlsx files accumulate "phantom" cells: value-less, unformatted
327
+ // `<c>` elements that a stray fill-to-the-right or a bloated stored
328
+ // `<dimension>` left behind (e.g. a sheet whose data ends at column G but that
329
+ // carries empty cells out to column XDD). The parser materializes them
330
+ // faithfully, so they land in the model carrying no data and no formatting —
331
+ // yet they inflate the used range, which drives BOTH the canvas render width
332
+ // and the exported `<dimension>`. A file bloated to column 16,332 renders ~1.5M
333
+ // pixels wide.
334
+ //
335
+ // A structural row/column op is the natural normalization point (Excel itself
336
+ // tightens the used range when you insert/delete): before shifting cells, we
337
+ // drop phantom cells that lie OUTSIDE the bounding box of the cells that
338
+ // actually carry information, clamping the sheet to its real extent. We never
339
+ // drop a cell with a value, a formula, a non-default style, a number format, an
340
+ // error, rich text, or a hyperlink — only truly inert padding.
341
+
342
+ /**
343
+ * A cell carrying zero information: no value, no formula, the default (empty)
344
+ * style, the General number format, no error/rich-text, and plain empty
345
+ * content (so hyperlinks are excluded). Such a cell is indistinguishable from
346
+ * an absent cell — Excel renders and evaluates them identically.
347
+ */
348
+ function isInertCell(cell: CellModel): boolean {
349
+ return (
350
+ (cell.value === null || cell.value === "") &&
351
+ cell.formula === undefined &&
352
+ cell.styleIndex === 0 &&
353
+ (cell.originalXfIndex === undefined || cell.originalXfIndex === 0) &&
354
+ (cell.numFmtCode === "General" || cell.numFmtCode === "") &&
355
+ cell.richText === undefined &&
356
+ cell.error === undefined &&
357
+ cell.content.type === "plain" &&
358
+ cell.content.text === ""
359
+ );
360
+ }
361
+
362
+ /**
363
+ * Drop inert phantom cells that fall outside the bounding box of the sheet's
364
+ * meaningful (non-inert) cells, clamping the used range to its real extent.
365
+ * Returns the removed cells keyed by ref so the caller can restore them on undo.
366
+ */
367
+ function clampInertCells(sheet: SheetModel): Map<string, CellModel> {
368
+ let minRow = Infinity;
369
+ let maxRow = 0;
370
+ let minCol = Infinity;
371
+ let maxCol = 0;
372
+ let hasMeaningful = false;
373
+ for (const [ref, cell] of sheet.cells) {
374
+ if (isInertCell(cell)) continue;
375
+ const rc = refToRowColSafe(ref);
376
+ if (!rc) continue;
377
+ hasMeaningful = true;
378
+ if (rc.row < minRow) minRow = rc.row;
379
+ if (rc.row > maxRow) maxRow = rc.row;
380
+ if (rc.col < minCol) minCol = rc.col;
381
+ if (rc.col > maxCol) maxCol = rc.col;
382
+ }
383
+
384
+ const pruned = new Map<string, CellModel>();
385
+ for (const [ref, cell] of sheet.cells) {
386
+ if (!isInertCell(cell)) continue;
387
+ // No meaningful cells at all → every phantom is padding. Otherwise, only
388
+ // phantoms strictly outside the meaningful bounding box are padding; a
389
+ // phantom interspersed with real data is left untouched (it doesn't inflate
390
+ // the used range and dropping it would be a needless mutation).
391
+ if (!hasMeaningful) {
392
+ pruned.set(ref, cell);
393
+ continue;
394
+ }
395
+ const rc = refToRowColSafe(ref);
396
+ if (!rc) continue;
397
+ if (rc.row < minRow || rc.row > maxRow || rc.col < minCol || rc.col > maxCol) {
398
+ pruned.set(ref, cell);
399
+ }
400
+ }
401
+
402
+ for (const ref of pruned.keys()) sheet.cells.delete(ref);
403
+ return pruned;
404
+ }
405
+
322
406
  // =============================================================================
323
407
  // Insert Rows
324
408
  // =============================================================================
@@ -334,11 +418,13 @@ export function insertRowsCommand(
334
418
  // the inverse of `adjustMergedCellsForRowInsert` and avoids edge cases when
335
419
  // inserts split existing ranges.
336
420
  let mergeSnapshot: string[] = [];
421
+ let inertSnapshot = new Map<string, CellModel>();
337
422
 
338
423
  return {
339
424
  description: `Insert ${count} row${count > 1 ? "s" : ""} at ${at}`,
340
425
  execute() {
341
426
  mergeSnapshot = [...sheet.mergedCells];
427
+ inertSnapshot = clampInertCells(sheet);
342
428
  // Shift existing cells down
343
429
  const toMove: Array<{ ref: string; row: number; col: number }> = [];
344
430
  for (const [ref] of sheet.cells) {
@@ -388,6 +474,7 @@ export function insertRowsCommand(
388
474
  sheet.rowHeights.set(r - count, h);
389
475
  }
390
476
  for (let r = at; r < at + count; r++) sheet.rowHeights.delete(r);
477
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
391
478
  workbook.emit({ type: "rows_deleted", sheet: sheetIndex, at, count });
392
479
  },
393
480
  };
@@ -413,11 +500,13 @@ export function deleteRowsCommand(
413
500
  }
414
501
  }
415
502
  let mergeSnapshot: string[] = [];
503
+ let inertSnapshot = new Map<string, CellModel>();
416
504
 
417
505
  return {
418
506
  description: `Delete ${count} row${count > 1 ? "s" : ""} at ${at}`,
419
507
  execute() {
420
508
  mergeSnapshot = [...sheet.mergedCells];
509
+ inertSnapshot = clampInertCells(sheet);
421
510
  // Delete cells in the range
422
511
  for (const ref of snapshot.keys()) sheet.cells.delete(ref);
423
512
  // Shift cells up
@@ -454,6 +543,7 @@ export function deleteRowsCommand(
454
543
  for (const [ref, snap] of snapshot) {
455
544
  sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
456
545
  }
546
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
457
547
  workbook.emit({ type: "rows_inserted", sheet: sheetIndex, at, count });
458
548
  },
459
549
  };
@@ -471,11 +561,13 @@ export function insertColsCommand(
471
561
  ): Command {
472
562
  const sheet = workbook.sheets[sheetIndex];
473
563
  let mergeSnapshot: string[] = [];
564
+ let inertSnapshot = new Map<string, CellModel>();
474
565
 
475
566
  return {
476
567
  description: `Insert ${count} column${count > 1 ? "s" : ""} at ${at}`,
477
568
  execute() {
478
569
  mergeSnapshot = [...sheet.mergedCells];
570
+ inertSnapshot = clampInertCells(sheet);
479
571
  const toMove: Array<{ ref: string; row: number; col: number }> = [];
480
572
  for (const [ref] of sheet.cells) {
481
573
  const rc = refToRowColSafe(ref);
@@ -519,6 +611,7 @@ export function insertColsCommand(
519
611
  sheet.colWidths.set(c - count, w);
520
612
  }
521
613
  for (let c = at; c < at + count; c++) sheet.colWidths.delete(c);
614
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
522
615
  workbook.emit({ type: "cols_deleted", sheet: sheetIndex, at, count });
523
616
  },
524
617
  };
@@ -543,11 +636,13 @@ export function deleteColsCommand(
543
636
  }
544
637
  }
545
638
  let mergeSnapshot: string[] = [];
639
+ let inertSnapshot = new Map<string, CellModel>();
546
640
 
547
641
  return {
548
642
  description: `Delete ${count} column${count > 1 ? "s" : ""} at ${at}`,
549
643
  execute() {
550
644
  mergeSnapshot = [...sheet.mergedCells];
645
+ inertSnapshot = clampInertCells(sheet);
551
646
  for (const ref of snapshot.keys()) sheet.cells.delete(ref);
552
647
  const toMove: Array<{ ref: string; row: number; col: number }> = [];
553
648
  for (const [ref] of sheet.cells) {
@@ -580,6 +675,7 @@ export function deleteColsCommand(
580
675
  for (const [ref, snap] of snapshot) {
581
676
  sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
582
677
  }
678
+ for (const [ref, cell] of inertSnapshot) sheet.cells.set(ref, cell);
583
679
  workbook.emit({ type: "cols_inserted", sheet: sheetIndex, at, count });
584
680
  },
585
681
  };