@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.
- package/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- 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
|
+
}
|