@lotics/xlsx 0.1.0

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.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,911 @@
1
+ import type {
2
+ ParsedSpreadsheet,
3
+ ParsedSheet,
4
+ ParsedRow,
5
+ ParsedCell,
6
+ ParsedColumn,
7
+ CellStyle,
8
+ CellContent,
9
+ RichTextPart,
10
+ ParsedImage,
11
+ FreezePane,
12
+ MergedCellRange,
13
+ PageSetup,
14
+ PrintTitles,
15
+ HeaderFooter,
16
+ } from "./types";
17
+ import type { ParsedConditionalFormat } from "./excel_cf_types";
18
+ import type {
19
+ ParsedChart,
20
+ ParsedTable,
21
+ ParsedDrawing,
22
+ ParsedSparklineGroup,
23
+ AutoFilterRange,
24
+ ParsedDataValidation,
25
+ } from "./ooxml_chart_types";
26
+ import { formatNumFmt } from "./excel_numfmt";
27
+ import { colLettersToNum, refToRowCol, rowColToRef, packRef, unpackRef } from "./excel_utils";
28
+ import { allBorders } from "./style_helpers";
29
+ import { PivotTableModel } from "./pivot_model";
30
+
31
+ // =============================================================================
32
+ // Cell value type
33
+ // =============================================================================
34
+
35
+ export type CellValue = string | number | boolean | null;
36
+ export type { CellStyle } from "./types";
37
+
38
+ // =============================================================================
39
+ // CellMap — int-keyed cell storage with a Map-compatible API
40
+ // =============================================================================
41
+ //
42
+ // `SheetModel.cells` looks like a `Map<string, CellModel>` to its callers
43
+ // but internally stores entries keyed by an integer packed from (row, col).
44
+ // V8's Map operations are measurably faster on numeric keys than on strings,
45
+ // and iteration avoids string hashing entirely.
46
+ //
47
+ // The wrapper exposes the same API surface as the standard Map (get, set,
48
+ // has, delete, size, keys, values, entries, forEach, Symbol.iterator) so
49
+ // existing call sites continue to work unchanged. Iteration unpacks the
50
+ // int key back to an A1 ref on demand.
51
+
52
+ export class CellMap implements Iterable<[string, CellModel]> {
53
+ private store = new Map<number, CellModel>();
54
+
55
+ get size(): number {
56
+ return this.store.size;
57
+ }
58
+
59
+ get(ref: string): CellModel | undefined {
60
+ return this.store.get(packRef(ref));
61
+ }
62
+
63
+ set(ref: string, cell: CellModel): this {
64
+ const key = packRef(ref);
65
+ if (key < 0) return this;
66
+ this.store.set(key, cell);
67
+ return this;
68
+ }
69
+
70
+ has(ref: string): boolean {
71
+ return this.store.has(packRef(ref));
72
+ }
73
+
74
+ delete(ref: string): boolean {
75
+ return this.store.delete(packRef(ref));
76
+ }
77
+
78
+ clear(): void {
79
+ this.store.clear();
80
+ }
81
+
82
+ *keys(): IterableIterator<string> {
83
+ for (const k of this.store.keys()) yield unpackRef(k);
84
+ }
85
+
86
+ values(): IterableIterator<CellModel> {
87
+ return this.store.values();
88
+ }
89
+
90
+ *entries(): IterableIterator<[string, CellModel]> {
91
+ for (const [k, v] of this.store) yield [unpackRef(k), v];
92
+ }
93
+
94
+ forEach(callback: (value: CellModel, key: string, map: CellMap) => void): void {
95
+ for (const [k, v] of this.store) callback(v, unpackRef(k), this);
96
+ }
97
+
98
+ [Symbol.iterator](): IterableIterator<[string, CellModel]> {
99
+ return this.entries();
100
+ }
101
+
102
+ /**
103
+ * Direct packed-int access escape hatch for hot loops in the command
104
+ * layer (row/col insert/delete) that already work in (row, col) space
105
+ * and want to avoid the redundant unpack→repack round trip.
106
+ */
107
+ packedEntries(): IterableIterator<[number, CellModel]> {
108
+ return this.store.entries();
109
+ }
110
+
111
+ /** Direct packed-key set, for the same hot loops. */
112
+ setPacked(packed: number, cell: CellModel): void {
113
+ this.store.set(packed, cell);
114
+ }
115
+
116
+ /** Direct packed-key delete. */
117
+ deletePacked(packed: number): boolean {
118
+ return this.store.delete(packed);
119
+ }
120
+ }
121
+
122
+ // =============================================================================
123
+ // CellModel — mutable cell data
124
+ // =============================================================================
125
+
126
+ export interface CellModel {
127
+ value: CellValue;
128
+ displayValue: string;
129
+ formula?: string;
130
+ styleIndex: number;
131
+ richText?: RichTextPart[];
132
+ error?: string;
133
+ numFmtCode: string;
134
+ content: CellContent;
135
+ isArrayFormula?: boolean;
136
+ arrayRange?: string;
137
+ /**
138
+ * Original cellXfs index from `xl/styles.xml` at load time. When the writer
139
+ * detects no style mutations occurred (`workbook.styles.dirty === false`),
140
+ * it uses this index to passthrough the original styles part.
141
+ */
142
+ originalXfIndex?: number;
143
+ }
144
+
145
+ // =============================================================================
146
+ // Change events — fired on any mutation
147
+ // =============================================================================
148
+
149
+ export type ChangeEvent =
150
+ | { type: "cell_value"; sheet: number; ref: string; oldValue: CellValue; newValue: CellValue }
151
+ | { type: "cell_style"; sheet: number; ref: string; oldStyle: number; newStyle: number }
152
+ | { type: "rows_inserted"; sheet: number; at: number; count: number }
153
+ | { type: "rows_deleted"; sheet: number; at: number; count: number }
154
+ | { type: "cols_inserted"; sheet: number; at: number; count: number }
155
+ | { type: "cols_deleted"; sheet: number; at: number; count: number }
156
+ | { type: "merge"; sheet: number; ref: string }
157
+ | { type: "unmerge"; sheet: number; ref: string }
158
+ | { type: "sheet_added"; index: number }
159
+ | { type: "sheet_deleted"; index: number }
160
+ | { type: "sheet_renamed"; index: number; oldName: string; newName: string };
161
+
162
+ // =============================================================================
163
+ // StyleRegistry — deduplicated CellStyle storage
164
+ // =============================================================================
165
+
166
+ export class StyleRegistry {
167
+ private styles: CellStyle[] = [{}]; // Index 0 = empty/default style
168
+ private keyMap = new Map<string, number>();
169
+ /**
170
+ * Becomes true the first time a style not present in the original
171
+ * `xl/styles.xml` is interned. The writer uses this to skip rebuilding
172
+ * the styles part when no consumer has changed any style.
173
+ */
174
+ dirty = false;
175
+ /**
176
+ * Set of numFmtCodes recorded by `registerNumFmt()` at load time. New
177
+ * numFmtCodes encountered after `markClean()` flip `dirty` to true so
178
+ * the styles part gets regenerated to include the new code.
179
+ */
180
+ private knownNumFmts = new Set<string>(["General", ""]);
181
+
182
+ constructor() {
183
+ this.keyMap.set(styleKey({}), 0);
184
+ }
185
+
186
+ /** Get style by index. Returns empty style for invalid indices. */
187
+ get(index: number): CellStyle {
188
+ return this.styles[index] ?? {};
189
+ }
190
+
191
+ /** Number of registered styles. */
192
+ get size(): number {
193
+ return this.styles.length;
194
+ }
195
+
196
+ /** Find or create a style entry. Returns the index. */
197
+ intern(style: CellStyle): number {
198
+ const key = styleKey(style);
199
+ const existing = this.keyMap.get(key);
200
+ if (existing !== undefined) return existing;
201
+
202
+ const index = this.styles.length;
203
+ this.styles.push({ ...style });
204
+ this.keyMap.set(key, index);
205
+ this.dirty = true;
206
+ return index;
207
+ }
208
+
209
+ /** Create a new style by merging a partial override onto an existing style. Returns the index. */
210
+ merge(baseIndex: number, override: Partial<CellStyle>): number {
211
+ const base = this.get(baseIndex);
212
+ const merged: CellStyle = { ...base, ...override };
213
+ return this.intern(merged);
214
+ }
215
+
216
+ /** Record a numFmtCode in use. Flips `dirty` if the code was unknown. */
217
+ registerNumFmt(code: string | undefined): void {
218
+ if (!code || this.knownNumFmts.has(code)) return;
219
+ this.knownNumFmts.add(code);
220
+ this.dirty = true;
221
+ }
222
+
223
+ /**
224
+ * Reset the `dirty` flag and snapshot the current numFmt set as the
225
+ * baseline. Called once after loading from a parsed workbook so that
226
+ * subsequent mutations are detectable.
227
+ */
228
+ markClean(): void {
229
+ this.dirty = false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Produce a stable canonical key for a CellStyle to support deduplication.
235
+ *
236
+ * Implemented as an explicit fixed-order field walk rather than
237
+ * `JSON.stringify` so the hot path (placeholder engine, bulk format ops)
238
+ * avoids reflection and string allocation cost. Empty styles produce `""`.
239
+ *
240
+ * Two equal `CellStyle` shapes always produce the same key; two unequal
241
+ * shapes always produce different keys.
242
+ */
243
+ function styleKey(style: CellStyle): string {
244
+ const parts: string[] = [];
245
+ if (style.fontBold) parts.push("fb");
246
+ if (style.fontItalic) parts.push("fi");
247
+ if (style.fontStrike) parts.push("fs");
248
+ if (style.fontColor !== undefined) parts.push(`fc:${style.fontColor}`);
249
+ if (style.fontSize !== undefined) parts.push(`fz:${style.fontSize}`);
250
+ if (style.fontName !== undefined) parts.push(`fn:${style.fontName}`);
251
+ if (style.fontUnderline !== undefined) parts.push(`fu:${style.fontUnderline}`);
252
+ if (style.fontVertAlign !== undefined) parts.push(`fv:${style.fontVertAlign}`);
253
+ if (style.backgroundColor !== undefined) parts.push(`bg:${style.backgroundColor}`);
254
+ if (style.backgroundGradient !== undefined) parts.push(`bgr:${style.backgroundGradient}`);
255
+ if (style.backgroundPattern !== undefined) parts.push(`bp:${style.backgroundPattern}`);
256
+ if (style.patternType !== undefined) parts.push(`pt:${style.patternType}`);
257
+ if (style.gradientData !== undefined) parts.push(`gd:${JSON.stringify(style.gradientData)}`);
258
+ if (style.horizontalAlign !== undefined) parts.push(`ha:${style.horizontalAlign}`);
259
+ if (style.verticalAlign !== undefined) parts.push(`va:${style.verticalAlign}`);
260
+ if (style.wrapText) parts.push("wt");
261
+ if (style.shrinkToFit) parts.push("stf");
262
+ if (style.indent !== undefined) parts.push(`in:${style.indent}`);
263
+ if (style.textRotation !== undefined) parts.push(`tr:${style.textRotation}`);
264
+ if (style.readingOrder !== undefined) parts.push(`ro:${style.readingOrder}`);
265
+ if (style.borderTop) parts.push(`bt:${style.borderTop.width}|${style.borderTop.style}|${style.borderTop.color ?? ""}`);
266
+ if (style.borderRight) parts.push(`br:${style.borderRight.width}|${style.borderRight.style}|${style.borderRight.color ?? ""}`);
267
+ if (style.borderBottom) parts.push(`bb:${style.borderBottom.width}|${style.borderBottom.style}|${style.borderBottom.color ?? ""}`);
268
+ if (style.borderLeft) parts.push(`bl:${style.borderLeft.width}|${style.borderLeft.style}|${style.borderLeft.color ?? ""}`);
269
+ if (style.borderDiagonal) parts.push(`bd:${style.borderDiagonal.width}|${style.borderDiagonal.style}|${style.borderDiagonal.color ?? ""}`);
270
+ if (style.diagonalUp) parts.push("du");
271
+ if (style.diagonalDown) parts.push("dd");
272
+ return parts.join("|");
273
+ }
274
+
275
+ // =============================================================================
276
+ // SheetModel — mutable sheet with sparse cell storage
277
+ // =============================================================================
278
+
279
+ export class SheetModel {
280
+ name: string;
281
+ styles?: StyleRegistry;
282
+ cells = new CellMap();
283
+ colWidths = new Map<number, number>();
284
+ rowHeights = new Map<number, number>();
285
+ defaultColWidth = 10;
286
+ defaultRowHeight = 15;
287
+ mergedCells: string[] = [];
288
+ freeze?: { row: number; col: number };
289
+ hiddenRows = new Set<number>();
290
+ hiddenCols = new Set<number>();
291
+ rowOutlineLevels = new Map<number, number>();
292
+ colOutlineLevels = new Map<number, number>();
293
+ conditionalFormats: ParsedConditionalFormat[] = [];
294
+ images: ParsedImage[] = [];
295
+ charts: ParsedChart[] = [];
296
+ tables: ParsedTable[] = [];
297
+ drawings: ParsedDrawing[] = [];
298
+ dataValidations: ParsedDataValidation[] = [];
299
+ sparklineGroups: ParsedSparklineGroup[] = [];
300
+ pivotTables: PivotTableModel[] = [];
301
+ autoFilter?: AutoFilterRange;
302
+ rowPageBreaks: number[] = [];
303
+ colPageBreaks: number[] = [];
304
+ hyperlinks = new Map<string, string>();
305
+ view: {
306
+ showGridLines: boolean;
307
+ showHeaders: boolean;
308
+ zoomScale: number;
309
+ rightToLeft: boolean;
310
+ tabColor?: string;
311
+ } = {
312
+ showGridLines: true,
313
+ showHeaders: true,
314
+ zoomScale: 100,
315
+ rightToLeft: false,
316
+ };
317
+ pageSetup?: PageSetup;
318
+ printTitles?: PrintTitles;
319
+ headerFooter?: HeaderFooter;
320
+
321
+ constructor(name: string, styles?: StyleRegistry) {
322
+ this.name = name;
323
+ this.styles = styles;
324
+ }
325
+
326
+ /**
327
+ * Freeze the rows/cols above and to the left of `topLeftRef`.
328
+ * `setFreeze("B2")` freezes row 1 and column A; data area starts at B2.
329
+ * Pass `null` or omit to clear the freeze.
330
+ */
331
+ setFreeze(topLeftRef: string | null): void {
332
+ if (topLeftRef === null) {
333
+ this.freeze = undefined;
334
+ return;
335
+ }
336
+ const rc = refToRowCol(topLeftRef);
337
+ if (!rc) throw new Error(`Invalid cell reference: ${topLeftRef}`);
338
+ this.freeze = { row: rc.row - 1, col: rc.col - 1 };
339
+ }
340
+
341
+ /** Get cell, returning undefined for empty cells. */
342
+ getCell(ref: string): CellModel | undefined {
343
+ return this.cells.get(ref);
344
+ }
345
+
346
+ /**
347
+ * Set cell value with optional inline style. Interns style via the
348
+ * StyleRegistry when available. When `style` is supplied and matches the
349
+ * existing cell's style (or is identical to a previously interned style),
350
+ * the cell's `originalXfIndex` is preserved so that the writer can use
351
+ * styles-passthrough mode if no other mutation has dirtied the registry.
352
+ */
353
+ set(
354
+ ref: string,
355
+ value: CellValue,
356
+ style?: CellStyle,
357
+ numFmt?: string,
358
+ formula?: string,
359
+ ): CellModel {
360
+ const existing = this.cells.get(ref);
361
+ const styleIndex = style && this.styles ? this.styles.intern(style) : (existing?.styleIndex ?? 0);
362
+ const numFmtCode = numFmt ?? existing?.numFmtCode ?? "General";
363
+ if (this.styles) this.styles.registerNumFmt(numFmtCode);
364
+ const displayValue = computeDisplayValue(value, numFmtCode);
365
+ const content = buildContentFromValue(displayValue, this.hyperlinks.get(ref));
366
+ const cell: CellModel = {
367
+ value,
368
+ displayValue,
369
+ formula,
370
+ styleIndex,
371
+ numFmtCode,
372
+ content,
373
+ };
374
+ if (existing && existing.styleIndex === styleIndex && existing.numFmtCode === numFmtCode && existing.originalXfIndex !== undefined) {
375
+ cell.originalXfIndex = existing.originalXfIndex;
376
+ }
377
+ this.cells.set(ref, cell);
378
+ return cell;
379
+ }
380
+
381
+ /**
382
+ * Fast path: set a cell with an already-interned style index, skipping
383
+ * both the `JSON.stringify` styleKey hash AND the eager `displayValue`/
384
+ * `content` materialization that `set()` performs.
385
+ *
386
+ * Optimized for hot loops like the Excel template placeholder engine
387
+ * where every cell of every expanded row reuses the same template style
388
+ * and the resulting workbook will be exported (not canvas-rendered) — the
389
+ * xlsx writer reads `cell.value` directly, so the formatted display value
390
+ * is never needed.
391
+ *
392
+ * If a consumer later needs `displayValue` for one of these cells, call
393
+ * `materializeDisplayValue(cell)` to compute and memoize it.
394
+ *
395
+ * Pass `originalXfIndex` to enable styles-passthrough mode for cells
396
+ * copied from a parsed template.
397
+ */
398
+ setWithStyleIndex(
399
+ ref: string,
400
+ value: CellValue,
401
+ styleIndex: number,
402
+ numFmtCode: string = "General",
403
+ formula?: string,
404
+ originalXfIndex?: number,
405
+ ): CellModel {
406
+ if (this.styles) this.styles.registerNumFmt(numFmtCode);
407
+ const cell: CellModel = {
408
+ value,
409
+ displayValue: "",
410
+ formula,
411
+ styleIndex,
412
+ numFmtCode,
413
+ content: { type: "plain", text: "" },
414
+ };
415
+ if (originalXfIndex !== undefined) cell.originalXfIndex = originalXfIndex;
416
+ this.cells.set(ref, cell);
417
+ return cell;
418
+ }
419
+
420
+ /** Delete cell (make empty). Returns true if cell existed. */
421
+ deleteCell(ref: string): boolean {
422
+ return this.cells.delete(ref);
423
+ }
424
+
425
+ /** Add a merged cell range. Optionally set the top-left cell value and style. */
426
+ merge(range: string, value?: CellValue, style?: CellStyle): void {
427
+ this.mergedCells.push(range);
428
+ if (value !== undefined) {
429
+ const topLeft = range.split(":")[0];
430
+ this.set(topLeft, value, style);
431
+ }
432
+ }
433
+
434
+ /** Set column width by letter ("A") or zero-based index (0). */
435
+ setColumnWidth(col: string | number, width: number): void {
436
+ const idx = typeof col === "string" ? colLettersToNum(col) : col;
437
+ this.colWidths.set(idx, width);
438
+ }
439
+
440
+ /** Set row height. Row is 1-based. */
441
+ setRowHeight(row: number, height: number): void {
442
+ this.rowHeights.set(row, height);
443
+ }
444
+
445
+ /** Write a row of header cells: bold, centered, bordered, with background color. */
446
+ headerRow(row: number, labels: string[], options?: { background?: string }): void {
447
+ const bg = options?.background ?? "#D9E1F2";
448
+ const style: CellStyle = {
449
+ fontBold: true,
450
+ horizontalAlign: "center",
451
+ backgroundColor: bg,
452
+ ...allBorders(),
453
+ };
454
+ labels.forEach((label, i) => {
455
+ const ref = rowColToRef(row, i + 1);
456
+ this.set(ref, label, style);
457
+ });
458
+ }
459
+
460
+ /** Compute the bounding box of all non-empty cells. */
461
+ getUsedRange(): { minRow: number; maxRow: number; minCol: number; maxCol: number } {
462
+ let minRow = Infinity;
463
+ let maxRow = 0;
464
+ let minCol = Infinity;
465
+ let maxCol = 0;
466
+
467
+ for (const ref of this.cells.keys()) {
468
+ const rc = refToRowCol(ref);
469
+ if (!rc) continue;
470
+ if (rc.row < minRow) minRow = rc.row;
471
+ if (rc.row > maxRow) maxRow = rc.row;
472
+ if (rc.col < minCol) minCol = rc.col;
473
+ if (rc.col > maxCol) maxCol = rc.col;
474
+ }
475
+
476
+ if (maxRow === 0) return { minRow: 1, maxRow: 1, minCol: 1, maxCol: 1 };
477
+ return { minRow, maxRow, minCol, maxCol };
478
+ }
479
+ }
480
+
481
+ // =============================================================================
482
+ // WorkbookModel — mutable workbook root
483
+ // =============================================================================
484
+
485
+ export class WorkbookModel {
486
+ sheets: SheetModel[];
487
+ activeSheetIndex: number;
488
+ styles: StyleRegistry;
489
+ date1904: boolean;
490
+ namedRanges = new Map<string, string>();
491
+ /**
492
+ * Excel's `calcPr.fullCalcOnLoad` flag. When true, callers that render
493
+ * cells (frontend canvas, PDF converters) must recompute all formulas
494
+ * via `FormulaEngine.evaluateAll()` before trusting cached values.
495
+ * Excel honors this automatically; we have to opt in explicitly.
496
+ */
497
+ fullCalcOnLoad: boolean;
498
+ /**
499
+ * Primary onChange callback. Kept as a writable property for back-compat with
500
+ * existing code that does `wb.onChange = (e) => ...`. New consumers should
501
+ * prefer `subscribe()` so multiple listeners can coexist.
502
+ */
503
+ onChange: ((event: ChangeEvent) => void) | undefined;
504
+ private subscribers: Set<(event: ChangeEvent) => void> = new Set();
505
+
506
+ constructor(opts: {
507
+ sheets: SheetModel[];
508
+ activeSheetIndex: number;
509
+ styles: StyleRegistry;
510
+ date1904?: boolean;
511
+ fullCalcOnLoad?: boolean;
512
+ }) {
513
+ this.sheets = opts.sheets;
514
+ this.activeSheetIndex = opts.activeSheetIndex;
515
+ this.styles = opts.styles;
516
+ this.date1904 = opts.date1904 ?? false;
517
+ this.fullCalcOnLoad = opts.fullCalcOnLoad ?? false;
518
+ }
519
+
520
+ /** Register an additional change listener. Returns an unsubscribe callback. */
521
+ subscribe(listener: (event: ChangeEvent) => void): () => void {
522
+ this.subscribers.add(listener);
523
+ return () => {
524
+ this.subscribers.delete(listener);
525
+ };
526
+ }
527
+
528
+ /** Emit a change event to the primary listener and every subscriber. */
529
+ emit(event: ChangeEvent): void {
530
+ this.onChange?.(event);
531
+ for (const sub of this.subscribers) sub(event);
532
+ }
533
+
534
+ /** Produce an immutable ParsedSpreadsheet snapshot for read-only consumers. */
535
+ toSnapshot(): ParsedSpreadsheet {
536
+ return {
537
+ sheets: this.sheets.map((sheet) => sheetToSnapshot(sheet, this.styles)),
538
+ activeSheetIndex: this.activeSheetIndex,
539
+ };
540
+ }
541
+ }
542
+
543
+ // =============================================================================
544
+ // Snapshot conversion: SheetModel → ParsedSheet
545
+ // =============================================================================
546
+
547
+ function sheetToSnapshot(sheet: SheetModel, styles: StyleRegistry): ParsedSheet {
548
+ const { maxRow, maxCol } = sheet.getUsedRange();
549
+
550
+ // Group cells by row
551
+ const rowMap = new Map<number, ParsedCell[]>();
552
+ for (const [ref, cell] of sheet.cells) {
553
+ const rc = refToRowCol(ref);
554
+ if (!rc) continue;
555
+ let arr = rowMap.get(rc.row);
556
+ if (!arr) {
557
+ arr = [];
558
+ rowMap.set(rc.row, arr);
559
+ }
560
+ const style = styles.get(cell.styleIndex);
561
+ const hyperlink = sheet.hyperlinks.get(ref);
562
+ const content = hyperlink
563
+ ? { type: "hyperlink" as const, text: cell.displayValue !== "" ? cell.displayValue : hyperlink, url: hyperlink }
564
+ : cell.richText
565
+ ? { type: "rich_text" as const, parts: cell.richText }
566
+ : cell.content;
567
+
568
+ const parsedCell: ParsedCell = {
569
+ column: rc.col,
570
+ value: cell.displayValue,
571
+ rawValue: typeof cell.value === "number" ? cell.value : undefined,
572
+ content,
573
+ style,
574
+ };
575
+ if (cell.error) parsedCell.error = cell.error;
576
+ if (cell.formula) parsedCell.formula = cell.formula;
577
+ if (cell.isArrayFormula) parsedCell.isArrayFormula = true;
578
+ if (cell.arrayRange) parsedCell.arrayRange = cell.arrayRange;
579
+ arr.push(parsedCell);
580
+ }
581
+
582
+ // Build sorted rows (include hidden rows with hidden flag, matching column behavior)
583
+ const rows: ParsedRow[] = [];
584
+ const sortedRowNums = Array.from(rowMap.keys()).sort((a, b) => a - b);
585
+ for (const rowNum of sortedRowNums) {
586
+ const cells = rowMap.get(rowNum)!;
587
+ cells.sort((a, b) => a.column - b.column);
588
+ rows.push({
589
+ index: rowNum,
590
+ height: sheet.rowHeights.get(rowNum) ?? sheet.defaultRowHeight,
591
+ cells,
592
+ hidden: sheet.hiddenRows.has(rowNum),
593
+ });
594
+ }
595
+
596
+ // Build columns
597
+ const columns: ParsedColumn[] = [];
598
+ for (let c = 1; c <= maxCol; c++) {
599
+ columns.push({
600
+ index: c,
601
+ width: sheet.colWidths.get(c) ?? sheet.defaultColWidth,
602
+ hidden: sheet.hiddenCols.has(c),
603
+ });
604
+ }
605
+
606
+ // Build merged cells
607
+ const mergedCells: MergedCellRange[] = sheet.mergedCells.map((ref) => {
608
+ const match = ref.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
609
+ if (!match) return { startRow: 1, startCol: 1, endRow: 1, endCol: 1 };
610
+ return {
611
+ startCol: colLettersToNum(match[1]),
612
+ startRow: parseInt(match[2], 10),
613
+ endCol: colLettersToNum(match[3]),
614
+ endRow: parseInt(match[4], 10),
615
+ };
616
+ });
617
+
618
+ // Build freeze pane
619
+ let freezePane: FreezePane | undefined;
620
+ if (sheet.freeze) {
621
+ freezePane = { frozenRows: sheet.freeze.row, frozenCols: sheet.freeze.col };
622
+ }
623
+
624
+ return {
625
+ name: sheet.name,
626
+ columns,
627
+ rows,
628
+ mergedCells,
629
+ totalRowCount: maxRow,
630
+ truncated: false,
631
+ defaultRowHeight: sheet.defaultRowHeight,
632
+ defaultColWidth: sheet.defaultColWidth,
633
+ showGridLines: sheet.view.showGridLines,
634
+ freezePane,
635
+ conditionalFormats: sheet.conditionalFormats,
636
+ images: sheet.images,
637
+ rowOutlineLevels: sheet.rowOutlineLevels,
638
+ colOutlineLevels: sheet.colOutlineLevels,
639
+ charts: sheet.charts,
640
+ tables: sheet.tables,
641
+ drawings: sheet.drawings,
642
+ dataValidations: sheet.dataValidations.length > 0 ? sheet.dataValidations : undefined,
643
+ sparklineGroups: sheet.sparklineGroups.length > 0 ? sheet.sparklineGroups : undefined,
644
+ autoFilter: sheet.autoFilter,
645
+ rowPageBreaks: sheet.rowPageBreaks.length > 0 ? sheet.rowPageBreaks : undefined,
646
+ colPageBreaks: sheet.colPageBreaks.length > 0 ? sheet.colPageBreaks : undefined,
647
+ pageSetup: sheet.pageSetup,
648
+ printTitles: sheet.printTitles,
649
+ headerFooter: sheet.headerFooter,
650
+ };
651
+ }
652
+
653
+
654
+ // =============================================================================
655
+ // Factory: ParsedSpreadsheet → WorkbookModel
656
+ // =============================================================================
657
+
658
+ export function loadWorkbookFromSnapshot(
659
+ parsed: ParsedSpreadsheet,
660
+ date1904 = false,
661
+ ): WorkbookModel {
662
+ const styles = new StyleRegistry();
663
+ const sheets: SheetModel[] = [];
664
+
665
+ for (const ps of parsed.sheets) {
666
+ const sheet = loadSheetFromParsed(ps, styles);
667
+ sheets.push(sheet);
668
+ }
669
+
670
+ const wb = new WorkbookModel({
671
+ sheets,
672
+ activeSheetIndex: parsed.activeSheetIndex,
673
+ styles,
674
+ date1904,
675
+ fullCalcOnLoad: parsed.fullCalcOnLoad,
676
+ });
677
+ if (parsed.namedRanges) {
678
+ wb.namedRanges = new Map(parsed.namedRanges);
679
+ }
680
+
681
+ // Build pivot table models. Caches live at workbook scope; tables live on
682
+ // the sheet they were placed on. Pivots without a resolvable cache are
683
+ // dropped — Excel would mark them as needing refresh, but in our model
684
+ // there's nothing meaningful to render so we skip them.
685
+ if (parsed.pivotCaches && parsed.pivotCaches.size > 0) {
686
+ for (let i = 0; i < parsed.sheets.length; i++) {
687
+ const ps = parsed.sheets[i];
688
+ if (!ps.pivotTables || ps.pivotTables.length === 0) continue;
689
+ const sheet = wb.sheets[i];
690
+ for (const config of ps.pivotTables) {
691
+ const cache = parsed.pivotCaches.get(config.cacheId);
692
+ if (!cache) continue;
693
+ const model = new PivotTableModel(config, cache);
694
+ // First-pass recompute so initial render shows current numbers.
695
+ model.recompute(wb);
696
+ sheet.pivotTables.push(model);
697
+ }
698
+ }
699
+ }
700
+
701
+ // Snapshot the post-load state as the clean baseline. Subsequent mutations
702
+ // (intern, registerNumFmt) will flip dirty back to true.
703
+ styles.markClean();
704
+ return wb;
705
+ }
706
+
707
+ function loadSheetFromParsed(ps: ParsedSheet, styles: StyleRegistry): SheetModel {
708
+ const sheet = new SheetModel(ps.name, styles);
709
+
710
+ // Metadata
711
+ sheet.defaultRowHeight = ps.defaultRowHeight;
712
+ sheet.defaultColWidth = ps.defaultColWidth;
713
+ sheet.view.showGridLines = ps.showGridLines;
714
+ sheet.conditionalFormats = [...ps.conditionalFormats];
715
+ sheet.images = [...ps.images];
716
+ sheet.charts = ps.charts ? [...ps.charts] : [];
717
+ sheet.tables = ps.tables ? [...ps.tables] : [];
718
+ sheet.drawings = ps.drawings ? [...ps.drawings] : [];
719
+ sheet.dataValidations = ps.dataValidations ? [...ps.dataValidations] : [];
720
+ sheet.sparklineGroups = ps.sparklineGroups ? [...ps.sparklineGroups] : [];
721
+ if (ps.autoFilter) sheet.autoFilter = ps.autoFilter;
722
+ sheet.rowPageBreaks = ps.rowPageBreaks ? [...ps.rowPageBreaks] : [];
723
+ sheet.colPageBreaks = ps.colPageBreaks ? [...ps.colPageBreaks] : [];
724
+ sheet.rowOutlineLevels = new Map(ps.rowOutlineLevels);
725
+ sheet.colOutlineLevels = new Map(ps.colOutlineLevels);
726
+ if (ps.pageSetup) sheet.pageSetup = ps.pageSetup;
727
+ if (ps.printTitles) sheet.printTitles = ps.printTitles;
728
+ if (ps.headerFooter) sheet.headerFooter = ps.headerFooter;
729
+
730
+ // Freeze pane
731
+ if (ps.freezePane) {
732
+ sheet.freeze = { row: ps.freezePane.frozenRows, col: ps.freezePane.frozenCols };
733
+ }
734
+
735
+ // Columns
736
+ for (const col of ps.columns) {
737
+ if (col.width !== ps.defaultColWidth) {
738
+ sheet.colWidths.set(col.index, col.width);
739
+ }
740
+ if (col.hidden) {
741
+ sheet.hiddenCols.add(col.index);
742
+ }
743
+ }
744
+
745
+ // Merged cells
746
+ sheet.mergedCells = ps.mergedCells.map((mc) => {
747
+ const startRef = rowColToRef(mc.startRow, mc.startCol);
748
+ const endRef = rowColToRef(mc.endRow, mc.endCol);
749
+ return `${startRef}:${endRef}`;
750
+ });
751
+
752
+ // Rows and cells
753
+ for (const row of ps.rows) {
754
+ if (row.height !== ps.defaultRowHeight) {
755
+ sheet.rowHeights.set(row.index, row.height);
756
+ }
757
+ if (row.hidden) {
758
+ sheet.hiddenRows.add(row.index);
759
+ }
760
+
761
+ for (const cell of row.cells) {
762
+ const ref = rowColToRef(row.index, cell.column);
763
+ const styleIndex = styles.intern(cell.style);
764
+ styles.registerNumFmt(cell.numFmtCode);
765
+
766
+ // Use typed value from parser when available (lossless round-trip),
767
+ // fall back to inferring from display value for programmatically-built cells
768
+ const rawValue = cell.typedValue !== undefined ? cell.typedValue : inferRawValue(cell.value, cell.content);
769
+
770
+ const model: CellModel = {
771
+ value: rawValue,
772
+ displayValue: cell.value,
773
+ styleIndex,
774
+ numFmtCode: cell.numFmtCode ?? "General",
775
+ content: cell.content,
776
+ };
777
+ if (cell.error) model.error = cell.error;
778
+ if (cell.formula) model.formula = cell.formula;
779
+ if (cell.isArrayFormula) model.isArrayFormula = true;
780
+ if (cell.arrayRange) model.arrayRange = cell.arrayRange;
781
+ if (cell.originalXfIndex !== undefined) model.originalXfIndex = cell.originalXfIndex;
782
+ if (cell.content.type === "rich_text") {
783
+ model.richText = cell.content.parts;
784
+ }
785
+ if (cell.content.type === "hyperlink") {
786
+ sheet.hyperlinks.set(ref, cell.content.url);
787
+ }
788
+
789
+ sheet.cells.set(ref, model);
790
+ }
791
+ }
792
+
793
+ // Apply auto-filter initial state — hide rows that don't match filter criteria
794
+ if (sheet.autoFilter && sheet.autoFilter.columns.length > 0) {
795
+ applyAutoFilterHiddenRows(sheet);
796
+ }
797
+
798
+ return sheet;
799
+ }
800
+
801
+ /** Hide rows that don't match the auto-filter criteria. */
802
+ function applyAutoFilterHiddenRows(sheet: SheetModel): void {
803
+ const af = sheet.autoFilter;
804
+ if (!af) return;
805
+
806
+ const refMatch = af.ref.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
807
+ if (!refMatch) return;
808
+
809
+ const startCol = colLettersToNum(refMatch[1]);
810
+ const headerRow = parseInt(refMatch[2], 10);
811
+ const endRow = parseInt(refMatch[4], 10);
812
+
813
+ // Only process columns that have active filters
814
+ const activeFilters = af.columns.filter(
815
+ (col) => (col.filterValues && col.filterValues.length > 0) || col.customFilter,
816
+ );
817
+ if (activeFilters.length === 0) return;
818
+
819
+ // Check each data row (skip header)
820
+ for (let r = headerRow + 1; r <= endRow; r++) {
821
+ let visible = true;
822
+ for (const filterCol of activeFilters) {
823
+ const col = startCol + filterCol.colIndex;
824
+ const cell = sheet.getCell(rowColToRef(r, col));
825
+ const cellText = cell?.displayValue ?? "";
826
+
827
+ if (filterCol.filterValues && filterCol.filterValues.length > 0) {
828
+ // Value filter: cell must match one of the allowed values
829
+ if (!filterCol.filterValues.includes(cellText)) {
830
+ visible = false;
831
+ break;
832
+ }
833
+ } else if (filterCol.customFilter) {
834
+ // Custom filter (e.g., greaterThan, lessThan)
835
+ const num = parseFloat(cellText);
836
+ const threshold = parseFloat(filterCol.customFilter.value);
837
+ if (!isNaN(num) && !isNaN(threshold)) {
838
+ if (!matchesCustomFilter(num, filterCol.customFilter.operator, threshold)) {
839
+ visible = false;
840
+ break;
841
+ }
842
+ }
843
+ }
844
+ }
845
+
846
+ if (!visible) {
847
+ sheet.hiddenRows.add(r);
848
+ }
849
+ }
850
+ }
851
+
852
+ function matchesCustomFilter(value: number, operator: string, threshold: number): boolean {
853
+ switch (operator) {
854
+ case "greaterThan": return value > threshold;
855
+ case "greaterThanOrEqual": return value >= threshold;
856
+ case "lessThan": return value < threshold;
857
+ case "lessThanOrEqual": return value <= threshold;
858
+ case "equal": return value === threshold;
859
+ case "notEqual": return value !== threshold;
860
+ default: return true;
861
+ }
862
+ }
863
+
864
+ /** Infer the raw CellValue from the display string and content type. */
865
+ function inferRawValue(displayValue: string, content: CellContent): CellValue {
866
+ if (!displayValue) return null;
867
+ if (displayValue === "TRUE") return true;
868
+ if (displayValue === "FALSE") return false;
869
+
870
+ // Try to parse as number
871
+ const num = Number(displayValue);
872
+ if (!isNaN(num) && displayValue.trim() !== "") return num;
873
+
874
+ // String value
875
+ if (content.type === "hyperlink") return content.text;
876
+ if (content.type === "rich_text") {
877
+ return content.parts.map((p) => p.text).join("");
878
+ }
879
+ return displayValue;
880
+ }
881
+
882
+ // =============================================================================
883
+ // Helpers
884
+ // =============================================================================
885
+
886
+ function computeDisplayValue(value: CellValue, numFmtCode: string): string {
887
+ if (value === null) return "";
888
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
889
+ if (typeof value === "string") return value;
890
+ return formatNumFmt(value, numFmtCode).text;
891
+ }
892
+
893
+ function buildContentFromValue(displayValue: string, hyperlink?: string): CellContent {
894
+ if (hyperlink) {
895
+ return { type: "hyperlink", text: displayValue !== "" ? displayValue : hyperlink, url: hyperlink };
896
+ }
897
+ return { type: "plain", text: displayValue };
898
+ }
899
+
900
+ /**
901
+ * Backfill `displayValue` and `content` on a cell created via the
902
+ * `setWithStyleIndex` fast path. Idempotent — safe to call repeatedly.
903
+ * Returns the (possibly newly computed) display value.
904
+ */
905
+ export function materializeDisplayValue(cell: CellModel, hyperlink?: string): string {
906
+ if (cell.displayValue === "" && cell.value !== null && cell.value !== "") {
907
+ cell.displayValue = computeDisplayValue(cell.value, cell.numFmtCode);
908
+ cell.content = buildContentFromValue(cell.displayValue, hyperlink);
909
+ }
910
+ return cell.displayValue;
911
+ }