@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,1271 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type {
3
+ ParsedSheet,
4
+ ParsedRow,
5
+ ParsedCell,
6
+ ParsedColumn,
7
+ ParsedImage,
8
+ FreezePane,
9
+ MergedCellRange,
10
+ PageSetup,
11
+ HeaderFooter,
12
+ } from "./types";
13
+ import type { StyleResolver } from "./ooxml_styles";
14
+ import type { SharedStringEntry } from "./ooxml_workbook";
15
+ import type { ThemeData } from "./ooxml_color";
16
+ import { parseColorElement, resolveColor, prefixHex } from "./ooxml_color";
17
+ import { decodeXmlEntities } from "./xml_entities";
18
+ import { formatCellValue, buildCellContent } from "./ooxml_cell_format";
19
+ import { colLettersToNum } from "./excel_utils";
20
+ import type {
21
+ ParsedConditionalFormat,
22
+ ParsedCfRule,
23
+ ParsedStyleRule,
24
+ CfThreshold,
25
+ } from "./excel_cf_types";
26
+ import type {
27
+ ParsedDataValidation,
28
+ ParsedSparklineGroup,
29
+ ParsedSparkline,
30
+ AutoFilterRange,
31
+ AutoFilterColumn,
32
+ } from "./ooxml_chart_types";
33
+ import { decodeUtf8 } from "./ooxml_zip";
34
+
35
+ // =============================================================================
36
+ // Constants
37
+ // =============================================================================
38
+
39
+ const MAX_ROWS_PER_SHEET = 10_000;
40
+ const MAX_IMAGES = 50;
41
+
42
+ // fast-xml-parser returns a string for `<el>foo</el>` but an object
43
+ // `{ "#text": "foo", "@_xml:space": "preserve", ... }` when the element
44
+ // carries any attribute. Excel emits `xml:space="preserve"` whenever cell
45
+ // or formula text contains leading/trailing whitespace or newlines, so
46
+ // every site that reads element text must normalize both shapes.
47
+ function extractElementText(node: unknown): string | undefined {
48
+ if (node === undefined || node === null) return undefined;
49
+ if (typeof node === "object") {
50
+ const text = (node as Record<string, unknown>)["#text"];
51
+ return text !== undefined ? String(text) : "";
52
+ }
53
+ return String(node);
54
+ }
55
+
56
+ // =============================================================================
57
+ // XML parser
58
+ // =============================================================================
59
+
60
+ const xmlParser = new XMLParser({
61
+ ignoreAttributes: false,
62
+ attributeNamePrefix: "@_",
63
+ processEntities: false,
64
+ isArray: (tagName) =>
65
+ tagName === "row" ||
66
+ tagName === "c" ||
67
+ tagName === "col" ||
68
+ tagName === "mergeCell" ||
69
+ tagName === "conditionalFormatting" ||
70
+ tagName === "cfRule" ||
71
+ tagName === "cfvo" ||
72
+ tagName === "color" ||
73
+ tagName === "hyperlink" ||
74
+ tagName === "Relationship" ||
75
+ tagName === "sheetView" ||
76
+ tagName === "xdr:twoCellAnchor" ||
77
+ tagName === "xdr:oneCellAnchor" ||
78
+ tagName === "dataValidation" ||
79
+ tagName === "filterColumn" ||
80
+ tagName === "filter" ||
81
+ tagName === "brk" ||
82
+ tagName === "ext",
83
+ // Preserve whitespace in cell values
84
+ trimValues: false,
85
+ });
86
+
87
+ const relsParser = new XMLParser({
88
+ ignoreAttributes: false,
89
+ attributeNamePrefix: "@_",
90
+ processEntities: false,
91
+ isArray: (tagName) => tagName === "Relationship",
92
+ });
93
+
94
+ const drawingParser = new XMLParser({
95
+ ignoreAttributes: false,
96
+ attributeNamePrefix: "@_",
97
+ processEntities: false,
98
+ isArray: (tagName) =>
99
+ tagName === "xdr:twoCellAnchor" ||
100
+ tagName === "xdr:oneCellAnchor" ||
101
+ tagName === "Relationship",
102
+ });
103
+
104
+ // =============================================================================
105
+ // Main sheet parsing
106
+ // =============================================================================
107
+
108
+ export function parseSheet(
109
+ sheetXml: string,
110
+ sheetRelsXml: string | undefined,
111
+ styles: StyleResolver,
112
+ sharedStrings: SharedStringEntry[],
113
+ theme: ThemeData,
114
+ indexedColors: string[],
115
+ zipEntries: Record<string, Uint8Array>,
116
+ date1904: boolean,
117
+ ): ParsedSheet {
118
+ const doc = xmlParser.parse(sheetXml);
119
+ const worksheet = doc?.["worksheet"];
120
+ if (!worksheet) return emptySheet("Sheet");
121
+
122
+ // Parse relationships for this sheet (hyperlinks, comments, drawings)
123
+ const rels = sheetRelsXml ? parseSheetRels(sheetRelsXml) : new Map<string, { target: string; type: string }>();
124
+
125
+ // Sheet format defaults
126
+ const sheetFormatPr = worksheet["sheetFormatPr"] as Record<string, string> | undefined;
127
+ const defaultRowHeight = sheetFormatPr?.["@_defaultRowHeight"]
128
+ ? parseFloat(sheetFormatPr["@_defaultRowHeight"])
129
+ : 15;
130
+ const defaultColWidth = sheetFormatPr?.["@_defaultColWidth"]
131
+ ? parseFloat(sheetFormatPr["@_defaultColWidth"])
132
+ : 8;
133
+
134
+ // Sheet views (freeze panes, grid lines)
135
+ const { freezePane, showGridLines } = parseSheetViews(worksheet);
136
+
137
+ // Column definitions
138
+ const { columns: parsedCols, colHidden, colOutlineLevels } = parseCols(worksheet, defaultColWidth);
139
+
140
+ // Hyperlinks map (cell ref → URL)
141
+ const hyperlinkMap = parseHyperlinks(worksheet, rels);
142
+
143
+ // Parse sheet data (rows + cells)
144
+ const {
145
+ rows,
146
+ totalRowCount,
147
+ truncated,
148
+ maxCol,
149
+ maxContentWidth,
150
+ rowOutlineLevels,
151
+ } = parseSheetData(worksheet, styles, sharedStrings, hyperlinkMap, defaultRowHeight, date1904);
152
+
153
+ // Build final columns from parsed col defs and data bounds
154
+ const colCount = Math.max(parsedCols.length > 0 ? parsedCols[parsedCols.length - 1].max : 0, maxCol);
155
+ const columns = buildColumns(parsedCols, colHidden, maxContentWidth, colCount, defaultColWidth);
156
+
157
+ // Merged cells
158
+ const mergedCells = parseMergedCells(worksheet);
159
+
160
+ // Conditional formatting
161
+ const conditionalFormats = parseConditionalFormats(worksheet, theme, indexedColors);
162
+
163
+ // Images
164
+ const images = parseImages(rels, zipEntries);
165
+
166
+ // Data validations
167
+ const dataValidations = parseDataValidations(worksheet);
168
+
169
+ // Sparklines (from sheet extension list)
170
+ const sparklineGroups = parseSparklineGroups(worksheet);
171
+
172
+ // Auto-filter
173
+ const autoFilter = parseAutoFilter(worksheet);
174
+
175
+ const result: ParsedSheet = {
176
+ name: "",
177
+ columns,
178
+ rows,
179
+ mergedCells,
180
+ totalRowCount,
181
+ truncated,
182
+ defaultRowHeight,
183
+ defaultColWidth,
184
+ showGridLines,
185
+ freezePane,
186
+ conditionalFormats,
187
+ images,
188
+ rowOutlineLevels,
189
+ colOutlineLevels,
190
+ };
191
+ if (dataValidations.length > 0) result.dataValidations = dataValidations;
192
+ if (sparklineGroups.length > 0) result.sparklineGroups = sparklineGroups;
193
+ if (autoFilter) result.autoFilter = autoFilter;
194
+
195
+ // Page breaks
196
+ const { rowPageBreaks, colPageBreaks } = parsePageBreaks(worksheet);
197
+ if (rowPageBreaks.length > 0) result.rowPageBreaks = rowPageBreaks;
198
+ if (colPageBreaks.length > 0) result.colPageBreaks = colPageBreaks;
199
+
200
+ // Page setup + margins (only emitted when diverging from defaults)
201
+ const pageSetup = parsePageSetupElement(worksheet);
202
+ if (pageSetup) result.pageSetup = pageSetup;
203
+
204
+ // Header/footer
205
+ const headerFooter = parseHeaderFooterElement(worksheet);
206
+ if (headerFooter) result.headerFooter = headerFooter;
207
+
208
+ return result;
209
+ }
210
+
211
+ function parsePageSetupElement(worksheet: Record<string, unknown>): PageSetup | undefined {
212
+ const ps = worksheet["pageSetup"] as Record<string, string> | undefined;
213
+ const pm = worksheet["pageMargins"] as Record<string, string> | undefined;
214
+
215
+ const out: PageSetup = {};
216
+ if (ps) {
217
+ const orient = ps["@_orientation"];
218
+ if (orient === "portrait" || orient === "landscape") out.orientation = orient;
219
+ if (ps["@_paperSize"]) out.paperSize = parseInt(ps["@_paperSize"], 10);
220
+ if (ps["@_fitToWidth"] !== undefined) out.fitToWidth = parseInt(ps["@_fitToWidth"], 10);
221
+ if (ps["@_fitToHeight"] !== undefined) out.fitToHeight = parseInt(ps["@_fitToHeight"], 10);
222
+ if (ps["@_scale"]) out.scale = parseInt(ps["@_scale"], 10);
223
+ }
224
+ if (pm) {
225
+ const margins: NonNullable<PageSetup["margins"]> = {};
226
+ const fields: Array<["left" | "right" | "top" | "bottom" | "header" | "footer", number]> = [
227
+ ["left", 0.7],
228
+ ["right", 0.7],
229
+ ["top", 0.75],
230
+ ["bottom", 0.75],
231
+ ["header", 0.3],
232
+ ["footer", 0.3],
233
+ ];
234
+ let anyNonDefault = false;
235
+ for (const [key, def] of fields) {
236
+ const raw = pm[`@_${key}`];
237
+ if (raw === undefined) continue;
238
+ const val = parseFloat(raw);
239
+ if (!Number.isNaN(val) && Math.abs(val - def) > 1e-6) {
240
+ margins[key] = val;
241
+ anyNonDefault = true;
242
+ }
243
+ }
244
+ if (anyNonDefault) out.margins = margins;
245
+ }
246
+ return Object.keys(out).length > 0 ? out : undefined;
247
+ }
248
+
249
+ function parseHeaderFooterElement(worksheet: Record<string, unknown>): HeaderFooter | undefined {
250
+ const hf = worksheet["headerFooter"] as Record<string, unknown> | undefined;
251
+ if (!hf) return undefined;
252
+ const out: HeaderFooter = {};
253
+ const readText = (key: string): string | undefined => {
254
+ const val = hf[key];
255
+ let raw: string | undefined;
256
+ if (typeof val === "string") raw = val;
257
+ else if (val && typeof val === "object" && "#text" in val && typeof (val as { "#text": unknown })["#text"] === "string") {
258
+ raw = (val as { "#text": string })["#text"];
259
+ }
260
+ return raw !== undefined ? decodeXmlEntities(raw) : undefined;
261
+ };
262
+ const oh = readText("oddHeader");
263
+ const of = readText("oddFooter");
264
+ const eh = readText("evenHeader");
265
+ const ef = readText("evenFooter");
266
+ const fh = readText("firstHeader");
267
+ const ff = readText("firstFooter");
268
+ if (oh !== undefined) out.oddHeader = oh;
269
+ if (of !== undefined) out.oddFooter = of;
270
+ if (eh !== undefined) out.evenHeader = eh;
271
+ if (ef !== undefined) out.evenFooter = ef;
272
+ if (fh !== undefined) out.firstHeader = fh;
273
+ if (ff !== undefined) out.firstFooter = ff;
274
+ if (hf["@_differentOddEven"] === "1") out.differentOddEven = true;
275
+ if (hf["@_differentFirst"] === "1") out.differentFirst = true;
276
+ return Object.keys(out).length > 0 ? out : undefined;
277
+ }
278
+
279
+ // =============================================================================
280
+ // Sheet relationships
281
+ // =============================================================================
282
+
283
+ function parseSheetRels(relsXml: string): Map<string, { target: string; type: string }> {
284
+ const doc = relsParser.parse(relsXml);
285
+ const relationships = doc?.["Relationships"];
286
+ if (!relationships) return new Map();
287
+
288
+ const relArr = relationships["Relationship"] as Array<Record<string, string>> | undefined;
289
+ if (!relArr) return new Map();
290
+
291
+ const map = new Map<string, { target: string; type: string }>();
292
+ for (const rel of relArr) {
293
+ const id = rel["@_Id"];
294
+ const target = rel["@_Target"];
295
+ const type = rel["@_Type"] ?? "";
296
+ if (id && target) {
297
+ map.set(id, { target, type });
298
+ }
299
+ }
300
+ return map;
301
+ }
302
+
303
+ // =============================================================================
304
+ // Sheet views
305
+ // =============================================================================
306
+
307
+ function parseSheetViews(worksheet: Record<string, unknown>): {
308
+ freezePane?: FreezePane;
309
+ showGridLines: boolean;
310
+ } {
311
+ const views = worksheet["sheetViews"] as Record<string, unknown> | undefined;
312
+ if (!views) return { showGridLines: true };
313
+
314
+ const viewArr = views["sheetView"];
315
+ const firstView = Array.isArray(viewArr) ? viewArr[0] : viewArr;
316
+ if (!firstView || typeof firstView !== "object") return { showGridLines: true };
317
+
318
+ const view = firstView as Record<string, unknown>;
319
+ const showGridLines = view["@_showGridLines"] !== "0";
320
+
321
+ let freezePane: FreezePane | undefined;
322
+ const pane = view["pane"] as Record<string, string> | undefined;
323
+ if (pane?.["@_state"] === "frozen") {
324
+ const frozenCols = parseInt(pane["@_xSplit"] ?? "0", 10);
325
+ const frozenRows = parseInt(pane["@_ySplit"] ?? "0", 10);
326
+ if (frozenRows > 0 || frozenCols > 0) {
327
+ freezePane = { frozenRows, frozenCols };
328
+ }
329
+ }
330
+
331
+ return { freezePane, showGridLines };
332
+ }
333
+
334
+ // =============================================================================
335
+ // Columns
336
+ // =============================================================================
337
+
338
+ interface ColDef {
339
+ min: number;
340
+ max: number;
341
+ width: number;
342
+ hidden: boolean;
343
+ outlineLevel: number;
344
+ }
345
+
346
+ function parseCols(
347
+ worksheet: Record<string, unknown>,
348
+ defaultColWidth: number,
349
+ ): {
350
+ columns: ColDef[];
351
+ colHidden: Set<number>;
352
+ colOutlineLevels: Map<number, number>;
353
+ } {
354
+ const colsEl = worksheet["cols"] as Record<string, unknown> | undefined;
355
+ const colHidden = new Set<number>();
356
+ const colOutlineLevels = new Map<number, number>();
357
+
358
+ if (!colsEl) return { columns: [], colHidden, colOutlineLevels };
359
+
360
+ const colArr = colsEl["col"] as Array<Record<string, string>> | undefined;
361
+ if (!colArr) return { columns: [], colHidden, colOutlineLevels };
362
+
363
+ const columns: ColDef[] = [];
364
+ for (const col of colArr) {
365
+ const min = parseInt(col["@_min"] ?? "1", 10);
366
+ const max = parseInt(col["@_max"] ?? "1", 10);
367
+ const width = col["@_width"] ? parseFloat(col["@_width"]) : defaultColWidth;
368
+ const hidden = col["@_hidden"] === "1" || col["@_hidden"] === "true";
369
+ const outlineLevel = parseInt(col["@_outlineLevel"] ?? "0", 10);
370
+
371
+ columns.push({ min, max, width, hidden, outlineLevel });
372
+
373
+ // Expand into individual column entries
374
+ for (let c = min; c <= max; c++) {
375
+ if (hidden) colHidden.add(c);
376
+ if (outlineLevel > 0) colOutlineLevels.set(c, outlineLevel);
377
+ }
378
+ }
379
+
380
+ return { columns, colHidden, colOutlineLevels };
381
+ }
382
+
383
+ function buildColumns(
384
+ colDefs: ColDef[],
385
+ colHidden: Set<number>,
386
+ maxContentWidth: Map<number, number>,
387
+ colCount: number,
388
+ _defaultColWidth: number,
389
+ ): ParsedColumn[] {
390
+ // Build a map of column index → explicit width from col definitions
391
+ const explicitWidth = new Map<number, number>();
392
+ for (const def of colDefs) {
393
+ for (let c = def.min; c <= def.max; c++) {
394
+ explicitWidth.set(c, def.width);
395
+ }
396
+ }
397
+
398
+ const columns: ParsedColumn[] = [];
399
+ for (let c = 1; c <= colCount; c++) {
400
+ const excelWidth = explicitWidth.get(c);
401
+ const contentChars = maxContentWidth.get(c) ?? 0;
402
+ const hidden = colHidden.has(c);
403
+
404
+ let width: number;
405
+ if (excelWidth && excelWidth > 8) {
406
+ width = excelWidth;
407
+ } else {
408
+ width = Math.max(8, Math.min(contentChars + 2, 40));
409
+ }
410
+
411
+ columns.push({ index: c, width, hidden });
412
+ }
413
+
414
+ return columns;
415
+ }
416
+
417
+ // =============================================================================
418
+ // Sheet data (rows + cells)
419
+ // =============================================================================
420
+
421
+ function parseSheetData(
422
+ worksheet: Record<string, unknown>,
423
+ styles: StyleResolver,
424
+ sharedStrings: SharedStringEntry[],
425
+ hyperlinkMap: Map<string, string>,
426
+ defaultRowHeight: number,
427
+ date1904: boolean,
428
+ ): {
429
+ rows: ParsedRow[];
430
+ totalRowCount: number;
431
+ truncated: boolean;
432
+ maxCol: number;
433
+ maxContentWidth: Map<number, number>;
434
+ rowOutlineLevels: Map<number, number>;
435
+ } {
436
+ const sheetData = worksheet["sheetData"] as Record<string, unknown> | undefined;
437
+ if (!sheetData) {
438
+ return {
439
+ rows: [],
440
+ totalRowCount: 0,
441
+ truncated: false,
442
+ maxCol: 0,
443
+ maxContentWidth: new Map(),
444
+ rowOutlineLevels: new Map(),
445
+ };
446
+ }
447
+
448
+ const rowArr = sheetData["row"] as Array<Record<string, unknown>> | undefined;
449
+ if (!rowArr) {
450
+ return {
451
+ rows: [],
452
+ totalRowCount: 0,
453
+ truncated: false,
454
+ maxCol: 0,
455
+ maxContentWidth: new Map(),
456
+ rowOutlineLevels: new Map(),
457
+ };
458
+ }
459
+
460
+ const totalRowCount = rowArr.length > 0
461
+ ? parseInt((rowArr[rowArr.length - 1]["@_r"] as string) ?? String(rowArr.length), 10)
462
+ : 0;
463
+
464
+ const rows: ParsedRow[] = [];
465
+ let maxCol = 0;
466
+ const maxContentWidth = new Map<number, number>();
467
+ const rowOutlineLevels = new Map<number, number>();
468
+ let rowCount = 0;
469
+
470
+ for (const rowEl of rowArr) {
471
+ const rowIndex = parseInt((rowEl["@_r"] as string) ?? "0", 10);
472
+ if (rowIndex === 0) continue;
473
+
474
+ // Track outline levels even if we skip the row
475
+ const outlineLevel = parseInt((rowEl["@_outlineLevel"] as string) ?? "0", 10);
476
+ if (outlineLevel > 0) {
477
+ rowOutlineLevels.set(rowIndex, outlineLevel);
478
+ }
479
+
480
+ // Skip hidden rows
481
+ if (rowEl["@_hidden"] === "1" || rowEl["@_hidden"] === "true") continue;
482
+
483
+ // Truncation check
484
+ rowCount++;
485
+ if (rowCount > MAX_ROWS_PER_SHEET) continue;
486
+
487
+ const height = rowEl["@_ht"]
488
+ ? parseFloat(rowEl["@_ht"] as string)
489
+ : defaultRowHeight;
490
+
491
+ const cellArr = rowEl["c"] as Array<Record<string, unknown>> | undefined;
492
+ const cells: ParsedCell[] = [];
493
+
494
+ if (cellArr) {
495
+ for (const cellEl of cellArr) {
496
+ const parsed = parseCell(cellEl, styles, sharedStrings, hyperlinkMap, date1904);
497
+ if (parsed) {
498
+ cells.push(parsed);
499
+ if (parsed.column > maxCol) maxCol = parsed.column;
500
+
501
+ // Track content width (sample first 200 rows)
502
+ if (rowCount <= 200) {
503
+ const currentMax = maxContentWidth.get(parsed.column) ?? 0;
504
+ if (parsed.value.length > currentMax) {
505
+ maxContentWidth.set(parsed.column, parsed.value.length);
506
+ }
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ rows.push({
513
+ index: rowIndex,
514
+ height,
515
+ cells,
516
+ hidden: false,
517
+ });
518
+ }
519
+
520
+ return {
521
+ rows,
522
+ totalRowCount,
523
+ truncated: rowCount > MAX_ROWS_PER_SHEET,
524
+ maxCol,
525
+ maxContentWidth,
526
+ rowOutlineLevels,
527
+ };
528
+ }
529
+
530
+ // =============================================================================
531
+ // Cell parsing
532
+ // =============================================================================
533
+
534
+ /** Parse cell reference like "A1" into { col: 1, ref: "A1" } */
535
+ function parseCellRef(ref: string): { col: number; row: number } | undefined {
536
+ const match = ref.match(/^([A-Z]+)(\d+)$/);
537
+ if (!match) return undefined;
538
+ return { col: colLettersToNum(match[1]), row: parseInt(match[2], 10) };
539
+ }
540
+
541
+ function parseCell(
542
+ cellEl: Record<string, unknown>,
543
+ styles: StyleResolver,
544
+ sharedStrings: SharedStringEntry[],
545
+ hyperlinkMap: Map<string, string>,
546
+ date1904: boolean,
547
+ ): ParsedCell | undefined {
548
+ const ref = cellEl["@_r"] as string | undefined;
549
+ if (!ref) return undefined;
550
+
551
+ const cellRef = parseCellRef(ref);
552
+ if (!cellRef) return undefined;
553
+
554
+ const type = (cellEl["@_t"] as string) ?? "";
555
+ const styleIndex = parseInt((cellEl["@_s"] as string) ?? "0", 10);
556
+
557
+ // Raw value
558
+ const rawValue = extractElementText(cellEl["v"]);
559
+
560
+ // Inline string: <c t="inlineStr"><is><t>text</t></is></c>
561
+ let inlineStr: string | undefined;
562
+ if (type === "inlineStr") {
563
+ const is = cellEl["is"] as Record<string, unknown> | undefined;
564
+ if (is) {
565
+ const raw = extractElementText(is["t"]);
566
+ inlineStr = raw !== undefined ? decodeXmlEntities(raw) : undefined;
567
+ }
568
+ }
569
+
570
+ // Resolve shared string
571
+ const ssIndex = type === "s" && rawValue !== undefined ? parseInt(rawValue, 10) : -1;
572
+ const sharedString = ssIndex >= 0 && ssIndex < sharedStrings.length ? sharedStrings[ssIndex] : undefined;
573
+
574
+ // Number format
575
+ const numFmt = styles.getNumFmt(styleIndex);
576
+
577
+ // Format display value
578
+ const effectiveValue = type === "inlineStr" ? (inlineStr ?? "") : rawValue;
579
+ const displayValue = formatCellValue(effectiveValue, type, numFmt, sharedString, date1904);
580
+
581
+ // Build content
582
+ const hyperlink = hyperlinkMap.get(ref);
583
+ const content = buildCellContent(displayValue, sharedString, hyperlink);
584
+
585
+ // Style
586
+ const style = styles.resolveStyle(styleIndex);
587
+
588
+ // Formula (detect array formulas)
589
+ const fEl = cellEl["f"];
590
+ let formula: string | undefined;
591
+ let isArrayFormula = false;
592
+ let arrayRange: string | undefined;
593
+ if (fEl !== undefined && fEl !== null) {
594
+ if (typeof fEl === "object") {
595
+ const fObj = fEl as Record<string, string>;
596
+ formula = fObj["#text"];
597
+ if (fObj["@_t"] === "array") {
598
+ isArrayFormula = true;
599
+ arrayRange = fObj["@_ref"];
600
+ // Strip {} braces from array formula text
601
+ if (formula?.startsWith("{") && formula.endsWith("}")) {
602
+ formula = formula.slice(1, -1);
603
+ }
604
+ }
605
+ } else {
606
+ formula = String(fEl);
607
+ }
608
+ if (formula !== undefined) formula = decodeXmlEntities(formula);
609
+ }
610
+
611
+ // Compute typed value for lossless round-trip export
612
+ let typedValue: string | number | boolean | null;
613
+ let cellRawValue: number | undefined;
614
+ if (type === "s") {
615
+ typedValue = sharedString?.text ?? "";
616
+ } else if (type === "b") {
617
+ typedValue = rawValue === "1";
618
+ } else if (type === "e") {
619
+ typedValue = rawValue ?? null;
620
+ } else if (type === "str" || type === "inlineStr") {
621
+ typedValue = effectiveValue ?? "";
622
+ } else if (rawValue !== undefined) {
623
+ const num = parseFloat(rawValue);
624
+ if (!isNaN(num)) {
625
+ typedValue = num;
626
+ cellRawValue = num;
627
+ } else {
628
+ typedValue = rawValue;
629
+ }
630
+ } else {
631
+ typedValue = null;
632
+ }
633
+
634
+ const cell: ParsedCell = { column: cellRef.col, value: displayValue, content, style };
635
+ cell.typedValue = typedValue;
636
+ cell.rawValue = cellRawValue;
637
+ cell.originalXfIndex = styleIndex;
638
+ if (numFmt !== "General") cell.numFmtCode = numFmt;
639
+ if (type === "e" && rawValue) cell.error = rawValue;
640
+ if (formula) cell.formula = formula;
641
+ if (isArrayFormula) cell.isArrayFormula = true;
642
+ if (arrayRange) cell.arrayRange = arrayRange;
643
+
644
+ return cell;
645
+ }
646
+
647
+ // =============================================================================
648
+ // Merged cells
649
+ // =============================================================================
650
+
651
+ function parseMergedCells(worksheet: Record<string, unknown>): MergedCellRange[] {
652
+ const mcEl = worksheet["mergeCells"] as Record<string, unknown> | undefined;
653
+ if (!mcEl) return [];
654
+
655
+ const mcArr = mcEl["mergeCell"] as Array<Record<string, string>> | undefined;
656
+ if (!mcArr) return [];
657
+
658
+ const ranges: MergedCellRange[] = [];
659
+ for (const mc of mcArr) {
660
+ const ref = mc["@_ref"];
661
+ if (!ref) continue;
662
+
663
+ const match = ref.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
664
+ if (!match) continue;
665
+
666
+ ranges.push({
667
+ startCol: colLettersToNum(match[1]),
668
+ startRow: parseInt(match[2], 10),
669
+ endCol: colLettersToNum(match[3]),
670
+ endRow: parseInt(match[4], 10),
671
+ });
672
+ }
673
+
674
+ return ranges;
675
+ }
676
+
677
+ // =============================================================================
678
+ // Hyperlinks
679
+ // =============================================================================
680
+
681
+ function parseHyperlinks(
682
+ worksheet: Record<string, unknown>,
683
+ rels: Map<string, { target: string; type: string }>,
684
+ ): Map<string, string> {
685
+ const map = new Map<string, string>();
686
+ const hlEl = worksheet["hyperlinks"] as Record<string, unknown> | undefined;
687
+ if (!hlEl) return map;
688
+
689
+ const hlArr = hlEl["hyperlink"] as Array<Record<string, string>> | undefined;
690
+ if (!hlArr) return map;
691
+
692
+ for (const hl of hlArr) {
693
+ const ref = hl["@_ref"];
694
+ if (!ref) continue;
695
+
696
+ // External hyperlink via relationship
697
+ const rId = hl["@_r:id"];
698
+ if (rId) {
699
+ const rel = rels.get(rId);
700
+ if (rel) {
701
+ map.set(ref, rel.target);
702
+ continue;
703
+ }
704
+ }
705
+
706
+ // Internal location
707
+ const location = hl["@_location"];
708
+ if (location) {
709
+ map.set(ref, `#${location}`);
710
+ }
711
+ }
712
+
713
+ return map;
714
+ }
715
+
716
+ // =============================================================================
717
+ // Images
718
+ // =============================================================================
719
+
720
+ function parseImages(
721
+ rels: Map<string, { target: string; type: string }>,
722
+ zipEntries: Record<string, Uint8Array>,
723
+ ): ParsedImage[] {
724
+ const images: ParsedImage[] = [];
725
+
726
+ // Find drawing relationship
727
+ for (const [, rel] of rels) {
728
+ if (!rel.type.includes("drawing") || rel.type.includes("drawingML")) continue;
729
+
730
+ const drawingPath = resolveRelPath(rel.target);
731
+ const drawingEntry = findZipEntry(zipEntries, drawingPath);
732
+ if (!drawingEntry) continue;
733
+
734
+ const drawingXml = decodeUtf8(drawingEntry);
735
+ const doc = drawingParser.parse(drawingXml);
736
+ const wsDr = doc?.["xdr:wsDr"];
737
+ if (!wsDr) continue;
738
+
739
+ // Parse drawing relationships
740
+ const drawingRelsPath = drawingPath.replace(/([^/]+)$/, "_rels/$1.rels");
741
+ const drawingRelsEntry = findZipEntry(zipEntries, drawingRelsPath);
742
+ const drawingRels = drawingRelsEntry
743
+ ? parseDrawingRels(decodeUtf8(drawingRelsEntry))
744
+ : new Map<string, string>();
745
+
746
+ // Two-cell anchors
747
+ const anchors = wsDr["xdr:twoCellAnchor"] as Array<Record<string, unknown>> | undefined;
748
+ if (anchors) {
749
+ for (const anchor of anchors) {
750
+ if (images.length >= MAX_IMAGES) break;
751
+ const image = extractImageFromAnchor(anchor, drawingRels, zipEntries, drawingPath);
752
+ if (image) images.push(image);
753
+ }
754
+ }
755
+ }
756
+
757
+ return images;
758
+ }
759
+
760
+ function parseDrawingRels(relsXml: string): Map<string, string> {
761
+ const doc = relsParser.parse(relsXml);
762
+ const relationships = doc?.["Relationships"];
763
+ if (!relationships) return new Map();
764
+
765
+ const relArr = relationships["Relationship"] as Array<Record<string, string>> | undefined;
766
+ if (!relArr) return new Map();
767
+
768
+ const map = new Map<string, string>();
769
+ for (const rel of relArr) {
770
+ const id = rel["@_Id"];
771
+ const target = rel["@_Target"];
772
+ if (id && target) {
773
+ map.set(id, target);
774
+ }
775
+ }
776
+ return map;
777
+ }
778
+
779
+ function extractImageFromAnchor(
780
+ anchor: Record<string, unknown>,
781
+ drawingRels: Map<string, string>,
782
+ zipEntries: Record<string, Uint8Array>,
783
+ drawingPath: string,
784
+ ): ParsedImage | undefined {
785
+ // Get position
786
+ const from = anchor["xdr:from"] as Record<string, string> | undefined;
787
+ const to = anchor["xdr:to"] as Record<string, string> | undefined;
788
+ if (!from || !to) return undefined;
789
+
790
+ const tl = {
791
+ row: parseInt(from["xdr:row"] ?? "0", 10),
792
+ col: parseInt(from["xdr:col"] ?? "0", 10),
793
+ };
794
+ const br = {
795
+ row: parseInt(to["xdr:row"] ?? "0", 10) + 1,
796
+ col: parseInt(to["xdr:col"] ?? "0", 10) + 1,
797
+ };
798
+
799
+ // Find the image reference
800
+ const pic = anchor["xdr:pic"] as Record<string, unknown> | undefined;
801
+ if (!pic) return undefined;
802
+
803
+ const blipFill = pic["xdr:blipFill"] as Record<string, unknown> | undefined;
804
+ if (!blipFill) return undefined;
805
+
806
+ const blip = blipFill["a:blip"] as Record<string, string> | undefined;
807
+ if (!blip) return undefined;
808
+
809
+ const embedId = blip["@_r:embed"];
810
+ if (!embedId) return undefined;
811
+
812
+ const imageRelTarget = drawingRels.get(embedId);
813
+ if (!imageRelTarget) return undefined;
814
+
815
+ // Resolve image path relative to drawing
816
+ const drawingDir = drawingPath.substring(0, drawingPath.lastIndexOf("/") + 1);
817
+ const imagePath = resolveRelativePath(drawingDir, imageRelTarget);
818
+ const imageEntry = findZipEntry(zipEntries, imagePath);
819
+ if (!imageEntry) return undefined;
820
+
821
+ // Build data URL
822
+ const ext = imagePath.split(".").pop()?.toLowerCase() ?? "png";
823
+ const mimeType = ext === "jpeg" || ext === "jpg" ? "image/jpeg" : ext === "gif" ? "image/gif" : "image/png";
824
+ const base64 = uint8ToBase64(imageEntry);
825
+ const dataUrl = `data:${mimeType};base64,${base64}`;
826
+
827
+ return { dataUrl, tl, br };
828
+ }
829
+
830
+ // =============================================================================
831
+ // Conditional formatting
832
+ // =============================================================================
833
+
834
+ function parseConditionalFormats(
835
+ worksheet: Record<string, unknown>,
836
+ theme: ThemeData,
837
+ indexedColors: string[],
838
+ ): ParsedConditionalFormat[] {
839
+ const cfArr = worksheet["conditionalFormatting"] as Array<Record<string, unknown>> | undefined;
840
+ if (!cfArr) return [];
841
+
842
+ const result: ParsedConditionalFormat[] = [];
843
+
844
+ for (const cf of cfArr) {
845
+ const ref = cf["@_sqref"] as string;
846
+ if (!ref) continue;
847
+
848
+ const ruleArr = cf["cfRule"] as Array<Record<string, unknown>> | undefined;
849
+ if (!ruleArr) continue;
850
+
851
+ const rules: ParsedCfRule[] = [];
852
+ for (const ruleEl of ruleArr) {
853
+ const parsed = parseCfRule(ruleEl, theme, indexedColors);
854
+ if (parsed) rules.push(parsed);
855
+ }
856
+
857
+ if (rules.length > 0) {
858
+ result.push({ ref, rules });
859
+ }
860
+ }
861
+
862
+ return result;
863
+ }
864
+
865
+ function parseCfRule(
866
+ el: Record<string, unknown>,
867
+ theme: ThemeData,
868
+ indexedColors: string[],
869
+ ): ParsedCfRule | undefined {
870
+ const type = el["@_type"] as string;
871
+ const priority = parseInt((el["@_priority"] as string) ?? "1", 10);
872
+ const stopIfTrue = (el["@_stopIfTrue"] as string) === "1";
873
+
874
+ switch (type) {
875
+ case "colorScale": {
876
+ const colorScale = el["colorScale"] as Record<string, unknown> | undefined;
877
+ if (!colorScale) return undefined;
878
+ const colorArr = colorScale["color"] as Array<Record<string, string>> | undefined;
879
+ if (!colorArr || colorArr.length < 2) return undefined;
880
+
881
+ const colors = colorArr.map((c) => {
882
+ const ooxmlColor = parseColorElement(c);
883
+ const hex = resolveColor(ooxmlColor, theme, indexedColors, "fill");
884
+ return hex ? prefixHex(hex) : "#000000";
885
+ });
886
+
887
+ return { ruleType: "colorScale", priority, stopIfTrue, colors };
888
+ }
889
+
890
+ case "dataBar": {
891
+ const dataBar = el["dataBar"] as Record<string, unknown> | undefined;
892
+ let color = "#638EC6";
893
+ if (dataBar) {
894
+ const colorArr = dataBar["color"] as Array<Record<string, string>> | undefined;
895
+ const colorEl = colorArr?.[0];
896
+ if (colorEl) {
897
+ const ooxmlColor = parseColorElement(colorEl);
898
+ const hex = resolveColor(ooxmlColor, theme, indexedColors, "fill");
899
+ if (hex) color = prefixHex(hex);
900
+ }
901
+ }
902
+
903
+ return {
904
+ ruleType: "dataBar",
905
+ priority,
906
+ stopIfTrue,
907
+ color,
908
+ minLength: parseInt((dataBar?.["@_minLength"] as string) ?? "10", 10),
909
+ maxLength: parseInt((dataBar?.["@_maxLength"] as string) ?? "90", 10),
910
+ showValue: (dataBar?.["@_showValue"] as string) !== "0",
911
+ };
912
+ }
913
+
914
+ case "iconSet": {
915
+ const iconSet = el["iconSet"] as Record<string, unknown> | undefined;
916
+ const cfvoArr = iconSet?.["cfvo"] as Array<Record<string, string>> | undefined;
917
+
918
+ return {
919
+ ruleType: "iconSet",
920
+ priority,
921
+ stopIfTrue,
922
+ iconSet: (iconSet?.["@_iconSet"] as string) ?? "3TrafficLights1",
923
+ thresholds: (cfvoArr ?? []).map((cfvo): CfThreshold => ({
924
+ type: cfvo["@_type"] as CfThreshold["type"],
925
+ value: cfvo["@_val"] !== undefined ? parseFloat(cfvo["@_val"]) : undefined,
926
+ })),
927
+ showValue: (iconSet?.["@_showValue"] as string) !== "0",
928
+ reverse: (iconSet?.["@_reverse"] as string) === "1",
929
+ };
930
+ }
931
+
932
+ case "cellIs":
933
+ case "containsText":
934
+ case "top10":
935
+ case "aboveAverage":
936
+ case "timePeriod":
937
+ case "expression": {
938
+ return parseStyleCfRule(el, type, priority, stopIfTrue, theme, indexedColors);
939
+ }
940
+
941
+ default:
942
+ return undefined;
943
+ }
944
+ }
945
+
946
+ function parseStyleCfRule(
947
+ el: Record<string, unknown>,
948
+ type: "cellIs" | "containsText" | "top10" | "aboveAverage" | "timePeriod" | "expression",
949
+ priority: number,
950
+ stopIfTrue: boolean,
951
+ _theme: ThemeData,
952
+ _indexedColors: string[],
953
+ ): ParsedStyleRule {
954
+ const rule: ParsedStyleRule = { ruleType: "style", priority, stopIfTrue, type };
955
+
956
+ if (type === "cellIs") {
957
+ rule.operator = el["@_operator"] as string | undefined;
958
+ const formula = el["formula"];
959
+ if (formula !== undefined) {
960
+ const arr = Array.isArray(formula) ? formula : [formula];
961
+ rule.formulae = arr
962
+ .map(extractElementText)
963
+ .filter((s): s is string => s !== undefined);
964
+ }
965
+ } else if (type === "containsText") {
966
+ rule.operator = el["@_operator"] as string | undefined;
967
+ rule.text = el["@_text"] as string | undefined;
968
+ } else if (type === "top10") {
969
+ rule.rank = el["@_rank"] !== undefined ? parseInt(el["@_rank"] as string, 10) : undefined;
970
+ rule.percent = el["@_percent"] === "1";
971
+ rule.bottom = el["@_bottom"] === "1";
972
+ } else if (type === "aboveAverage") {
973
+ rule.aboveAverage = el["@_aboveAverage"] !== "0";
974
+ } else if (type === "timePeriod") {
975
+ rule.timePeriod = el["@_timePeriod"] as string | undefined;
976
+ } else if (type === "expression") {
977
+ const formula = el["formula"];
978
+ if (formula !== undefined) {
979
+ const arr = Array.isArray(formula) ? formula : [formula];
980
+ rule.formulae = arr
981
+ .map(extractElementText)
982
+ .filter((s): s is string => s !== undefined);
983
+ }
984
+ }
985
+
986
+ return rule;
987
+ }
988
+
989
+ // =============================================================================
990
+ // Data validations
991
+ // =============================================================================
992
+
993
+ function parseDataValidations(worksheet: Record<string, unknown>): ParsedDataValidation[] {
994
+ const dvEl = worksheet["dataValidations"] as Record<string, unknown> | undefined;
995
+ if (!dvEl) return [];
996
+
997
+ const dvArr = dvEl["dataValidation"] as Array<Record<string, string>> | undefined;
998
+ if (!dvArr) return [];
999
+
1000
+ const result: ParsedDataValidation[] = [];
1001
+ for (const dv of dvArr) {
1002
+ const ref = dv["@_sqref"];
1003
+ if (!ref) continue;
1004
+
1005
+ const typeStr = dv["@_type"] ?? "none";
1006
+ const type = (["list", "whole", "decimal", "date", "time", "textLength", "custom", "none"].includes(typeStr)
1007
+ ? typeStr
1008
+ : "none") as ParsedDataValidation["type"];
1009
+
1010
+ const operatorStr = dv["@_operator"];
1011
+ const validOperators = ["between", "notBetween", "equal", "notEqual", "greaterThan", "lessThan", "greaterThanOrEqual", "lessThanOrEqual"];
1012
+ const operator = operatorStr && validOperators.includes(operatorStr)
1013
+ ? operatorStr as ParsedDataValidation["operator"]
1014
+ : undefined;
1015
+
1016
+ const formula1Text = extractElementText(dv["formula1"]);
1017
+ const formula1 = formula1Text !== undefined ? decodeXmlEntities(formula1Text) : undefined;
1018
+ const formula2Text = extractElementText(dv["formula2"]);
1019
+ const formula2 = formula2Text !== undefined ? decodeXmlEntities(formula2Text) : undefined;
1020
+ const decodeAttr = (v: string | undefined) => v !== undefined ? decodeXmlEntities(v) : undefined;
1021
+
1022
+ result.push({
1023
+ ref,
1024
+ type,
1025
+ operator,
1026
+ formula1,
1027
+ formula2,
1028
+ showDropdown: dv["@_showDropDown"] !== "1",
1029
+ errorStyle: dv["@_errorStyle"] as ParsedDataValidation["errorStyle"],
1030
+ errorTitle: decodeAttr(dv["@_errorTitle"]),
1031
+ errorMessage: decodeAttr(dv["@_error"]),
1032
+ promptTitle: decodeAttr(dv["@_promptTitle"]),
1033
+ promptMessage: decodeAttr(dv["@_prompt"]),
1034
+ });
1035
+ }
1036
+
1037
+ return result;
1038
+ }
1039
+
1040
+ // =============================================================================
1041
+ // Sparklines (sheet extension list)
1042
+ // =============================================================================
1043
+
1044
+ function parseSparklineGroups(worksheet: Record<string, unknown>): ParsedSparklineGroup[] {
1045
+ const extLst = worksheet["extLst"] as Record<string, unknown> | undefined;
1046
+ if (!extLst) return [];
1047
+
1048
+ const extArr = extLst["ext"] as Array<Record<string, unknown>> | undefined;
1049
+ if (!extArr) return [];
1050
+
1051
+ const result: ParsedSparklineGroup[] = [];
1052
+
1053
+ for (const ext of extArr) {
1054
+ // Look for sparklineGroups in any x14 namespace variant
1055
+ const sparkGroups = ext["x14:sparklineGroups"]
1056
+ ?? ext["sparklineGroups"] as Record<string, unknown> | undefined;
1057
+ if (!sparkGroups || typeof sparkGroups !== "object") continue;
1058
+
1059
+ const groupArr = ensureSparkArray(
1060
+ (sparkGroups as Record<string, unknown>)["x14:sparklineGroup"]
1061
+ ?? (sparkGroups as Record<string, unknown>)["sparklineGroup"],
1062
+ );
1063
+
1064
+ for (const group of groupArr) {
1065
+ if (!group || typeof group !== "object") continue;
1066
+ const g = group as Record<string, unknown>;
1067
+ const typeAttr = (g["@_type"] as string) ?? "line";
1068
+ const type = (["line", "column", "stacked"].includes(typeAttr) ? typeAttr : "line") as ParsedSparklineGroup["type"];
1069
+ const color = extractSparklineColor(g);
1070
+
1071
+ const sparklineContainer = g["x14:sparklines"] ?? g["sparklines"];
1072
+ if (!sparklineContainer || typeof sparklineContainer !== "object") continue;
1073
+
1074
+ const sparkArr = ensureSparkArray(
1075
+ (sparklineContainer as Record<string, unknown>)["x14:sparkline"]
1076
+ ?? (sparklineContainer as Record<string, unknown>)["sparkline"],
1077
+ );
1078
+
1079
+ const sparklines: ParsedSparkline[] = [];
1080
+ for (const sp of sparkArr) {
1081
+ if (!sp || typeof sp !== "object") continue;
1082
+ const s = sp as Record<string, unknown>;
1083
+ const dataRangeEl = s["xm:f"] ?? s["f"];
1084
+ const locationEl = s["xm:sqref"] ?? s["sqref"];
1085
+ const dataRange = extractElementText(dataRangeEl) ?? "";
1086
+ const locationRef = extractElementText(locationEl) ?? "";
1087
+ if (dataRange && locationRef) {
1088
+ sparklines.push({ dataRange, locationRef });
1089
+ }
1090
+ }
1091
+
1092
+ if (sparklines.length > 0) {
1093
+ result.push({ type, sparklines, color });
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ return result;
1099
+ }
1100
+
1101
+ function extractSparklineColor(group: Record<string, unknown>): string | undefined {
1102
+ const colorEl = group["x14:colorSeries"] ?? group["colorSeries"];
1103
+ if (!colorEl || typeof colorEl !== "object") return undefined;
1104
+ const rgb = (colorEl as Record<string, string>)["@_rgb"];
1105
+ if (!rgb) return undefined;
1106
+ // AARRGGBB → #RRGGBB
1107
+ return `#${rgb.length === 8 ? rgb.slice(2) : rgb}`;
1108
+ }
1109
+
1110
+ function ensureSparkArray(val: unknown): unknown[] {
1111
+ if (val === undefined || val === null) return [];
1112
+ return Array.isArray(val) ? val : [val];
1113
+ }
1114
+
1115
+ // =============================================================================
1116
+ // Page breaks
1117
+ // =============================================================================
1118
+
1119
+ function parsePageBreaks(worksheet: Record<string, unknown>): { rowPageBreaks: number[]; colPageBreaks: number[] } {
1120
+ const rowPageBreaks: number[] = [];
1121
+ const colPageBreaks: number[] = [];
1122
+
1123
+ const rowBreaksEl = worksheet["rowBreaks"] as Record<string, unknown> | undefined;
1124
+ if (rowBreaksEl) {
1125
+ const brkArr = ensureBreakArray(rowBreaksEl["brk"]);
1126
+ for (const brk of brkArr) {
1127
+ if (!brk || typeof brk !== "object") continue;
1128
+ const id = (brk as Record<string, string>)["@_id"];
1129
+ if (id) rowPageBreaks.push(parseInt(id, 10));
1130
+ }
1131
+ }
1132
+
1133
+ const colBreaksEl = worksheet["colBreaks"] as Record<string, unknown> | undefined;
1134
+ if (colBreaksEl) {
1135
+ const brkArr = ensureBreakArray(colBreaksEl["brk"]);
1136
+ for (const brk of brkArr) {
1137
+ if (!brk || typeof brk !== "object") continue;
1138
+ const id = (brk as Record<string, string>)["@_id"];
1139
+ if (id) colPageBreaks.push(parseInt(id, 10));
1140
+ }
1141
+ }
1142
+
1143
+ return { rowPageBreaks: rowPageBreaks.sort((a, b) => a - b), colPageBreaks: colPageBreaks.sort((a, b) => a - b) };
1144
+ }
1145
+
1146
+ function ensureBreakArray(val: unknown): unknown[] {
1147
+ if (val === undefined || val === null) return [];
1148
+ return Array.isArray(val) ? val : [val];
1149
+ }
1150
+
1151
+ // =============================================================================
1152
+ // Auto-filter
1153
+ // =============================================================================
1154
+
1155
+ function parseAutoFilter(worksheet: Record<string, unknown>): AutoFilterRange | undefined {
1156
+ const afEl = worksheet["autoFilter"] as Record<string, unknown> | undefined;
1157
+ if (!afEl) return undefined;
1158
+
1159
+ const ref = afEl["@_ref"] as string | undefined;
1160
+ if (!ref) return undefined;
1161
+
1162
+ const columns: AutoFilterColumn[] = [];
1163
+ const fcArr = afEl["filterColumn"] as Array<Record<string, unknown>> | undefined;
1164
+ if (fcArr) {
1165
+ for (const fc of fcArr) {
1166
+ const colIndex = parseInt((fc["@_colId"] as string) ?? "0", 10);
1167
+ const filterValues: string[] = [];
1168
+
1169
+ const filtersEl = fc["filters"] as Record<string, unknown> | undefined;
1170
+ if (filtersEl) {
1171
+ const filterArr = filtersEl["filter"] as Array<Record<string, string>> | undefined;
1172
+ if (filterArr) {
1173
+ for (const f of filterArr) {
1174
+ const val = f["@_val"];
1175
+ if (val !== undefined) filterValues.push(decodeXmlEntities(val));
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ const customFilterEl = fc["customFilters"] as Record<string, unknown> | undefined;
1181
+ let customFilter: AutoFilterColumn["customFilter"];
1182
+ if (customFilterEl) {
1183
+ const cf = customFilterEl["customFilter"] as Record<string, string> | undefined;
1184
+ if (cf) {
1185
+ customFilter = {
1186
+ operator: cf["@_operator"] ?? "equal",
1187
+ value: decodeXmlEntities(cf["@_val"] ?? ""),
1188
+ };
1189
+ }
1190
+ }
1191
+
1192
+ columns.push({
1193
+ colIndex,
1194
+ filterValues: filterValues.length > 0 ? filterValues : undefined,
1195
+ customFilter,
1196
+ });
1197
+ }
1198
+ }
1199
+
1200
+ return { ref, columns };
1201
+ }
1202
+
1203
+ // =============================================================================
1204
+ // Utility helpers
1205
+ // =============================================================================
1206
+
1207
+ function resolveRelPath(target: string): string {
1208
+ // Targets in rels are relative to the sheet's directory
1209
+ // e.g., "../comments1.xml" from xl/worksheets/ → xl/comments1.xml
1210
+ if (target.startsWith("/")) return target.slice(1);
1211
+ return `xl/${target.replace(/^\.\.\//, "")}`;
1212
+ }
1213
+
1214
+ function resolveRelativePath(basePath: string, relativePath: string): string {
1215
+ if (relativePath.startsWith("/")) return relativePath.slice(1);
1216
+
1217
+ const parts = basePath.split("/").filter(Boolean);
1218
+ const relParts = relativePath.split("/");
1219
+
1220
+ // Remove the file name from base path (keep directory)
1221
+ // basePath already ends with / so this is fine
1222
+
1223
+ const resolved = [...parts];
1224
+ for (const part of relParts) {
1225
+ if (part === "..") {
1226
+ resolved.pop();
1227
+ } else if (part !== ".") {
1228
+ resolved.push(part);
1229
+ }
1230
+ }
1231
+
1232
+ return resolved.join("/");
1233
+ }
1234
+
1235
+ function findZipEntry(zipEntries: Record<string, Uint8Array>, path: string): Uint8Array | undefined {
1236
+ // Try exact path
1237
+ if (zipEntries[path]) return zipEntries[path];
1238
+ // Try with leading xl/ prefix variations
1239
+ if (zipEntries[`xl/${path}`]) return zipEntries[`xl/${path}`];
1240
+ // Try without leading slash
1241
+ const noSlash = path.replace(/^\//, "");
1242
+ if (zipEntries[noSlash]) return zipEntries[noSlash];
1243
+ return undefined;
1244
+ }
1245
+
1246
+ function uint8ToBase64(bytes: Uint8Array): string {
1247
+ let binary = "";
1248
+ for (let i = 0; i < bytes.length; i++) {
1249
+ binary += String.fromCharCode(bytes[i]);
1250
+ }
1251
+ return btoa(binary);
1252
+ }
1253
+
1254
+ export function emptySheet(name: string): ParsedSheet {
1255
+ return {
1256
+ name,
1257
+ columns: [],
1258
+ rows: [],
1259
+ mergedCells: [],
1260
+ totalRowCount: 0,
1261
+ truncated: false,
1262
+ defaultRowHeight: 15,
1263
+ defaultColWidth: 10,
1264
+ showGridLines: true,
1265
+ conditionalFormats: [],
1266
+ images: [],
1267
+ rowOutlineLevels: new Map(),
1268
+ colOutlineLevels: new Map(),
1269
+ };
1270
+ }
1271
+