@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,1061 @@
1
+ import { parseExcelFile, parseExcelFromZip } from "./excel_parser";
2
+ import type { ParsedSpreadsheet, ParsedRow } from "./excel_parser";
3
+ import type { ParsedConditionalFormat } from "./excel_cf_types";
4
+ import { formatNumber } from "./ooxml_cell_format";
5
+ import { resolveColor, applyTint, parseTheme, prefixHex, normalizeColor, hexToArgb } from "./ooxml_color";
6
+ import type { ThemeData, OoxmlColor } from "./ooxml_color";
7
+ import { parseCsvText } from "./csv_parser";
8
+ import { getPatternFillCSS } from "./excel_pattern_fills";
9
+ import { getCfIcon } from "./excel_cf_icons";
10
+ import { evaluateConditionalFormats } from "./excel_cf_evaluate";
11
+ import { vi } from "vitest";
12
+ import ExcelJS from "exceljs";
13
+
14
+ // =============================================================================
15
+ // resolveColor
16
+ // =============================================================================
17
+
18
+ describe("resolveColor", () => {
19
+ const theme: ThemeData = {
20
+ colors: [
21
+ "FFFFFF", "000000", "E7E6E6", "44546A",
22
+ "4472C4", "ED7D31", "A5A5A5", "FFC000",
23
+ "5B9BD5", "70AD47", "0563C1", "954F72",
24
+ ],
25
+ majorFont: "Calibri",
26
+ minorFont: "Calibri",
27
+ };
28
+ const indexed = [
29
+ "000000", "FFFFFF", "FF0000", "00FF00", "0000FF", "FFFF00", "FF00FF", "00FFFF",
30
+ ];
31
+
32
+ test("resolves rgb color (8-char AARRGGBB)", () => {
33
+ const color: OoxmlColor = { rgb: "FFFF0000" };
34
+ expect(resolveColor(color, theme, indexed, "font")).toBe("FF0000");
35
+ });
36
+
37
+ test("resolves rgb color (6-char RRGGBB)", () => {
38
+ const color: OoxmlColor = { rgb: "00FF00" };
39
+ expect(resolveColor(color, theme, indexed, "font")).toBe("00FF00");
40
+ });
41
+
42
+ test("resolves theme color (theme=0 → lt1 = FFFFFF)", () => {
43
+ const color: OoxmlColor = { theme: 0 };
44
+ expect(resolveColor(color, theme, indexed, "font")).toBe("FFFFFF");
45
+ });
46
+
47
+ test("resolves theme color (theme=1 → dk1 = 000000)", () => {
48
+ const color: OoxmlColor = { theme: 1 };
49
+ expect(resolveColor(color, theme, indexed, "font")).toBe("000000");
50
+ });
51
+
52
+ test("resolves theme color with tint", () => {
53
+ const color: OoxmlColor = { theme: 1, tint: 0.5 };
54
+ const result = resolveColor(color, theme, indexed, "font");
55
+ expect(result).toBeDefined();
56
+ // Black with +0.5 tint should lighten to ~808080
57
+ expect(result).not.toBe("000000");
58
+ });
59
+
60
+ test("resolves indexed color", () => {
61
+ const color: OoxmlColor = { indexed: 2 };
62
+ expect(resolveColor(color, theme, indexed, "font")).toBe("FF0000");
63
+ });
64
+
65
+ test("returns undefined for auto color on fill", () => {
66
+ const color: OoxmlColor = { auto: true };
67
+ expect(resolveColor(color, theme, indexed, "fill")).toBeUndefined();
68
+ });
69
+
70
+ test("returns black for auto color on font", () => {
71
+ const color: OoxmlColor = { auto: true };
72
+ expect(resolveColor(color, theme, indexed, "font")).toBe("000000");
73
+ });
74
+
75
+ test("returns undefined for empty color", () => {
76
+ expect(resolveColor(undefined, theme, indexed, "font")).toBeUndefined();
77
+ expect(resolveColor({}, theme, indexed, "font")).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ // =============================================================================
82
+ // Color helpers — canonical #RRGGBB internal format
83
+ // =============================================================================
84
+
85
+ describe("prefixHex", () => {
86
+ test("prefixes 6-char hex with #", () => {
87
+ expect(prefixHex("FF0000")).toBe("#FF0000");
88
+ expect(prefixHex("ffffff")).toBe("#FFFFFF");
89
+ expect(prefixHex("000000")).toBe("#000000");
90
+ });
91
+
92
+ test("returns black on invalid input length", () => {
93
+ expect(prefixHex("FFF")).toBe("#000000");
94
+ expect(prefixHex("")).toBe("#000000");
95
+ });
96
+ });
97
+
98
+ describe("normalizeColor", () => {
99
+ test("accepts canonical #RRGGBB", () => {
100
+ expect(normalizeColor("#FF0000")).toBe("#FF0000");
101
+ expect(normalizeColor("#abcdef")).toBe("#ABCDEF");
102
+ });
103
+
104
+ test("accepts bare 6-char hex", () => {
105
+ expect(normalizeColor("FF0000")).toBe("#FF0000");
106
+ expect(normalizeColor("abcdef")).toBe("#ABCDEF");
107
+ });
108
+
109
+ test("accepts CSS rgb() and rgba()", () => {
110
+ expect(normalizeColor("rgb(255, 0, 0)")).toBe("#FF0000");
111
+ expect(normalizeColor("rgba(0, 51, 102, 1)")).toBe("#003366");
112
+ });
113
+
114
+ test("expands #RGB shorthand", () => {
115
+ expect(normalizeColor("#f00")).toBe("#FF0000");
116
+ expect(normalizeColor("#0a3")).toBe("#00AA33");
117
+ });
118
+
119
+ test("returns undefined for empty/invalid input", () => {
120
+ expect(normalizeColor(undefined)).toBeUndefined();
121
+ expect(normalizeColor("")).toBeUndefined();
122
+ expect(normalizeColor("not a color")).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ describe("hexToArgb", () => {
127
+ test("returns 8-char ARGB for OOXML", () => {
128
+ expect(hexToArgb("#FF0000")).toBe("FFFF0000");
129
+ expect(hexToArgb("rgb(0, 51, 102)")).toBe("FF003366");
130
+ expect(hexToArgb("003366")).toBe("FF003366");
131
+ });
132
+
133
+ test("falls back to opaque black on invalid input", () => {
134
+ expect(hexToArgb(undefined)).toBe("FF000000");
135
+ expect(hexToArgb("not a color")).toBe("FF000000");
136
+ });
137
+ });
138
+
139
+ // =============================================================================
140
+ // applyTint
141
+ // =============================================================================
142
+
143
+ describe("applyTint", () => {
144
+ test("positive tint lightens color", () => {
145
+ const result = applyTint("000000", 0.5);
146
+ // Black with +0.5 tint should produce a gray
147
+ expect(result).not.toBe("000000");
148
+ // Should be something around 808080
149
+ const r = parseInt(result.slice(0, 2), 16);
150
+ expect(r).toBeGreaterThan(64);
151
+ expect(r).toBeLessThan(160);
152
+ });
153
+
154
+ test("negative tint darkens color", () => {
155
+ const result = applyTint("FFFFFF", -0.5);
156
+ // White with -0.5 tint should produce a gray
157
+ expect(result).not.toBe("FFFFFF");
158
+ const r = parseInt(result.slice(0, 2), 16);
159
+ expect(r).toBeGreaterThan(64);
160
+ expect(r).toBeLessThan(160);
161
+ });
162
+
163
+ test("zero tint returns same color", () => {
164
+ expect(applyTint("FF0000", 0)).toBe("FF0000");
165
+ });
166
+ });
167
+
168
+ // =============================================================================
169
+ // parseTheme
170
+ // =============================================================================
171
+
172
+ describe("parseTheme", () => {
173
+ test("parses minimal theme XML", () => {
174
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
175
+ <a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Test">
176
+ <a:themeElements>
177
+ <a:clrScheme name="Office">
178
+ <a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
179
+ <a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
180
+ <a:dk2><a:srgbClr val="44546A"/></a:dk2>
181
+ <a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
182
+ <a:accent1><a:srgbClr val="4472C4"/></a:accent1>
183
+ <a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
184
+ <a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
185
+ <a:accent4><a:srgbClr val="FFC000"/></a:accent4>
186
+ <a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
187
+ <a:accent6><a:srgbClr val="70AD47"/></a:accent6>
188
+ <a:hlink><a:srgbClr val="0563C1"/></a:hlink>
189
+ <a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
190
+ </a:clrScheme>
191
+ <a:fontScheme name="Office">
192
+ <a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
193
+ <a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
194
+ </a:fontScheme>
195
+ </a:themeElements>
196
+ </a:theme>`;
197
+
198
+ const result = parseTheme(xml);
199
+
200
+ // Theme index 0 = lt1 (FFFFFF), index 1 = dk1 (000000) — swapped from XML order
201
+ expect(result.colors[0]).toBe("FFFFFF");
202
+ expect(result.colors[1]).toBe("000000");
203
+ // Theme index 2 = lt2, index 3 = dk2 — also swapped
204
+ expect(result.colors[2]).toBe("E7E6E6");
205
+ expect(result.colors[3]).toBe("44546A");
206
+ // Accents stay in order
207
+ expect(result.colors[4]).toBe("4472C4");
208
+ expect(result.majorFont).toBe("Calibri Light");
209
+ expect(result.minorFont).toBe("Calibri");
210
+ });
211
+ });
212
+
213
+ // =============================================================================
214
+ // formatNumber
215
+ // =============================================================================
216
+
217
+ describe("formatNumber", () => {
218
+ test("returns General format as plain string", () => {
219
+ expect(formatNumber(1234, "General")).toBe("1234");
220
+ });
221
+
222
+ test("formats percentages with decimals", () => {
223
+ expect(formatNumber(0.5, "0.00%")).toBe("50.00%");
224
+ });
225
+
226
+ test("formats percentages without decimals", () => {
227
+ expect(formatNumber(0.75, "0%")).toBe("75%");
228
+ });
229
+
230
+ test("formats with thousands separator", () => {
231
+ expect(formatNumber(1234567, "#,##0")).toBe("1,234,567");
232
+ });
233
+
234
+ test("formats with thousands separator and decimals", () => {
235
+ expect(formatNumber(1234.5, "#,##0.00")).toBe("1,234.50");
236
+ });
237
+
238
+ test("formats currency with dollar sign", () => {
239
+ expect(formatNumber(1234.56, "$#,##0.00")).toBe("$1,234.56");
240
+ });
241
+
242
+ test("formats currency with euro sign", () => {
243
+ expect(formatNumber(1234.56, "€#,##0.00")).toBe("€1,234.56");
244
+ });
245
+
246
+ test("formats quoted currency like VND", () => {
247
+ expect(formatNumber(50000, '#,##0 "VND"')).toBe("50,000 VND");
248
+ });
249
+
250
+ test("formats accounting negative in parens", () => {
251
+ expect(formatNumber(-1234.56, "#,##0.00_);(#,##0.00)")).toBe("(1,234.56)");
252
+ });
253
+
254
+ test("formats accounting positive with underscore spacing", () => {
255
+ expect(formatNumber(1234.56, "#,##0.00_);(#,##0.00)")).toBe("1,234.56 ");
256
+ });
257
+
258
+ test("formats negative numbers with minus sign by default", () => {
259
+ expect(formatNumber(-42, "#,##0")).toBe("-42");
260
+ });
261
+
262
+ test("formats zero", () => {
263
+ expect(formatNumber(0, "#,##0.00")).toBe("0.00");
264
+ });
265
+
266
+ test("handles empty format string", () => {
267
+ expect(formatNumber(42, "")).toBe("42");
268
+ });
269
+
270
+ test("formats fixed decimal places", () => {
271
+ expect(formatNumber(3.1, "0.000")).toBe("3.100");
272
+ });
273
+
274
+ test("formats integer format truncates decimals", () => {
275
+ expect(formatNumber(3.7, "0")).toBe("4");
276
+ });
277
+ });
278
+
279
+ // =============================================================================
280
+ // parseExcelFile (integration with in-memory workbook created by ExcelJS)
281
+ // =============================================================================
282
+
283
+ describe("parseExcelFile", () => {
284
+ const originalFetch = globalThis.fetch;
285
+
286
+ afterEach(() => {
287
+ globalThis.fetch = originalFetch;
288
+ });
289
+
290
+ function mockFetch(buffer: ExcelJS.Buffer): void {
291
+ globalThis.fetch = vi.fn().mockResolvedValue({
292
+ ok: true,
293
+ arrayBuffer: () => Promise.resolve(buffer),
294
+ });
295
+ }
296
+
297
+ test("parses a workbook with cell data and styles", async () => {
298
+ const workbook = new ExcelJS.Workbook();
299
+ const ws = workbook.addWorksheet("Sales");
300
+
301
+ ws.getCell("A1").value = "Product";
302
+ ws.getCell("A1").font = { bold: true };
303
+ ws.getCell("B1").value = "Revenue";
304
+ ws.getCell("B1").font = { bold: true };
305
+ ws.getCell("A2").value = "Widget";
306
+ ws.getCell("B2").value = 1234.56;
307
+ ws.getCell("A3").value = "Gadget";
308
+ ws.getCell("B3").value = 789;
309
+
310
+ const buffer = await workbook.xlsx.writeBuffer();
311
+ mockFetch(buffer);
312
+
313
+ const result: ParsedSpreadsheet = await parseExcelFile("https://example.com/test.xlsx");
314
+
315
+
316
+ expect(result.sheets).toHaveLength(1);
317
+ expect(result.sheets[0].name).toBe("Sales");
318
+ expect(result.sheets[0].rows.length).toBeGreaterThanOrEqual(3);
319
+
320
+ const headerRow = result.sheets[0].rows[0];
321
+ const productCell = headerRow.cells.find((c) => c.column === 1);
322
+ expect(productCell?.value).toBe("Product");
323
+ expect(productCell?.style.fontBold).toBe(true);
324
+
325
+ const dataRow = result.sheets[0].rows[1];
326
+ const widgetCell = dataRow.cells.find((c) => c.column === 1);
327
+ expect(widgetCell?.value).toBe("Widget");
328
+ });
329
+
330
+ test("parses merged cells", async () => {
331
+ const workbook = new ExcelJS.Workbook();
332
+ const ws = workbook.addWorksheet("Merged");
333
+
334
+ ws.getCell("A1").value = "Merged Title";
335
+ ws.mergeCells("A1:C1");
336
+ ws.getCell("A2").value = "Col1";
337
+ ws.getCell("B2").value = "Col2";
338
+ ws.getCell("C2").value = "Col3";
339
+
340
+ const buffer = await workbook.xlsx.writeBuffer();
341
+ mockFetch(buffer);
342
+
343
+ const result = await parseExcelFile("https://example.com/test.xlsx");
344
+
345
+
346
+ expect(result.sheets[0].mergedCells.length).toBeGreaterThanOrEqual(1);
347
+
348
+ const merge = result.sheets[0].mergedCells[0];
349
+ expect(merge.startRow).toBe(1);
350
+ expect(merge.startCol).toBe(1);
351
+ expect(merge.endRow).toBe(1);
352
+ expect(merge.endCol).toBe(3);
353
+ });
354
+
355
+ test("skips hidden sheets", async () => {
356
+ const workbook = new ExcelJS.Workbook();
357
+ workbook.addWorksheet("Visible");
358
+ const hidden = workbook.addWorksheet("Hidden");
359
+ hidden.state = "hidden";
360
+
361
+ const buffer = await workbook.xlsx.writeBuffer();
362
+ mockFetch(buffer);
363
+
364
+ const result = await parseExcelFile("https://example.com/test.xlsx");
365
+
366
+
367
+ expect(result.sheets).toHaveLength(1);
368
+ expect(result.sheets[0].name).toBe("Visible");
369
+ });
370
+
371
+ test("preserves cell text when <v> carries xml:space=\"preserve\"", () => {
372
+ // Excel/SheetJS emit `<v xml:space="preserve">` whenever the cell text
373
+ // has leading/trailing whitespace or newlines. fast-xml-parser then
374
+ // returns the element as an object, not a string, and a naive
375
+ // `String(vEl)` produces "[object Object]".
376
+ const enc = new TextEncoder();
377
+ const sheetXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
378
+ <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
379
+ <sheetData>
380
+ <row r="1"><c r="A1" t="str" xml:space="preserve"><v xml:space="preserve">DATE OF
381
+ EST</v></c></row>
382
+ <row r="2"><c r="A2" t="str"><v>plain</v></c></row>
383
+ </sheetData>
384
+ </worksheet>`;
385
+ const workbookXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
386
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
387
+ <sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets>
388
+ </workbook>`;
389
+ const wbRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
390
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
391
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
392
+ </Relationships>`;
393
+ const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
394
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
395
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
396
+ </Relationships>`;
397
+ const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
398
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
399
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
400
+ <Default Extension="xml" ContentType="application/xml"/>
401
+ <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
402
+ <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
403
+ </Types>`;
404
+ const zip: Record<string, Uint8Array> = {
405
+ "[Content_Types].xml": enc.encode(contentTypes),
406
+ "_rels/.rels": enc.encode(rootRels),
407
+ "xl/workbook.xml": enc.encode(workbookXml),
408
+ "xl/_rels/workbook.xml.rels": enc.encode(wbRels),
409
+ "xl/worksheets/sheet1.xml": enc.encode(sheetXml),
410
+ };
411
+
412
+ const parsed = parseExcelFromZip(zip);
413
+ const cellA1 = parsed.sheets[0].rows[0].cells.find((c) => c.column === 1);
414
+ const cellA2 = parsed.sheets[0].rows[1].cells.find((c) => c.column === 1);
415
+ expect(cellA1?.value).toBe("DATE OF\nEST");
416
+ expect(cellA1?.value).not.toBe("[object Object]");
417
+ expect(cellA2?.value).toBe("plain");
418
+ });
419
+
420
+ test("preserves inline-string and CF/DV formulas under xml:space=\"preserve\"", () => {
421
+ // Three sites that share the same fast-xml-parser shape bug as <v>:
422
+ // 1. <is><t xml:space="preserve">…</t></is> (inline strings)
423
+ // 2. <cfRule><formula xml:space="preserve">…</formula></cfRule>
424
+ // 3. <dataValidation><formula1 xml:space="preserve">…</formula1></dataValidation>
425
+ // All four sites use the shared `extractElementText` helper. This test
426
+ // exercises the three not covered above.
427
+ const enc = new TextEncoder();
428
+ const sheetXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
429
+ <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
430
+ <sheetData>
431
+ <row r="1"><c r="A1" t="inlineStr"><is><t xml:space="preserve"> hello world </t></is></c></row>
432
+ </sheetData>
433
+ <conditionalFormatting sqref="A1">
434
+ <cfRule type="expression" priority="1">
435
+ <formula xml:space="preserve">A1 &gt; 0</formula>
436
+ </cfRule>
437
+ </conditionalFormatting>
438
+ <dataValidations count="1">
439
+ <dataValidation type="list" allowBlank="1" sqref="A1">
440
+ <formula1 xml:space="preserve">"yes,no"</formula1>
441
+ </dataValidation>
442
+ </dataValidations>
443
+ </worksheet>`;
444
+ const workbookXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
445
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
446
+ <sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets>
447
+ </workbook>`;
448
+ const wbRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
449
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
450
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
451
+ </Relationships>`;
452
+ const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
453
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
454
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
455
+ </Relationships>`;
456
+ const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
457
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
458
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
459
+ <Default Extension="xml" ContentType="application/xml"/>
460
+ <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
461
+ <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
462
+ </Types>`;
463
+ const zip: Record<string, Uint8Array> = {
464
+ "[Content_Types].xml": enc.encode(contentTypes),
465
+ "_rels/.rels": enc.encode(rootRels),
466
+ "xl/workbook.xml": enc.encode(workbookXml),
467
+ "xl/_rels/workbook.xml.rels": enc.encode(wbRels),
468
+ "xl/worksheets/sheet1.xml": enc.encode(sheetXml),
469
+ };
470
+
471
+ const parsed = parseExcelFromZip(zip);
472
+ const sheet = parsed.sheets[0];
473
+
474
+ // Inline string with surrounding spaces — must round-trip the spaces, not "[object Object]".
475
+ expect(sheet.rows[0]?.cells[0]?.value).toBe(" hello world ");
476
+
477
+ // Conditional format formula — should preserve "A1 > 0", not "[object Object]".
478
+ const cf = sheet.conditionalFormats[0];
479
+ expect(cf).toBeTruthy();
480
+ const rule = cf.rules[0];
481
+ expect(rule.ruleType).toBe("style");
482
+ if (rule.ruleType === "style") {
483
+ expect(rule.formulae?.[0]).toContain("A1");
484
+ expect(rule.formulae?.[0]).not.toContain("[object Object]");
485
+ }
486
+
487
+ // Data validation formula1 — should preserve `"yes,no"`, not "[object Object]".
488
+ const dv = sheet.dataValidations?.[0];
489
+ expect(dv).toBeTruthy();
490
+ expect(dv?.formula1).toContain("yes");
491
+ expect(dv?.formula1).not.toContain("[object Object]");
492
+ });
493
+
494
+ test("handles empty workbook", async () => {
495
+ const workbook = new ExcelJS.Workbook();
496
+ const buffer = await workbook.xlsx.writeBuffer();
497
+ mockFetch(buffer);
498
+
499
+ const result = await parseExcelFile("https://example.com/test.xlsx");
500
+
501
+
502
+ expect(result.sheets).toHaveLength(1);
503
+ expect(result.sheets[0].name).toBe("Sheet1");
504
+ expect(result.sheets[0].rows).toHaveLength(0);
505
+ });
506
+
507
+ test("handles fetch error", async () => {
508
+ globalThis.fetch = vi.fn().mockResolvedValue({
509
+ ok: false,
510
+ status: 404,
511
+ statusText: "Not Found",
512
+ });
513
+
514
+ await expect(parseExcelFile("https://example.com/missing.xlsx")).rejects.toThrow(
515
+ "Failed to fetch file",
516
+ );
517
+ });
518
+
519
+ test("extracts background color", async () => {
520
+ const workbook = new ExcelJS.Workbook();
521
+ const ws = workbook.addWorksheet("Styled");
522
+
523
+ ws.getCell("A1").value = "Red Background";
524
+ ws.getCell("A1").fill = {
525
+ type: "pattern",
526
+ pattern: "solid",
527
+ fgColor: { argb: "FFFF0000" },
528
+ };
529
+
530
+ const buffer = await workbook.xlsx.writeBuffer();
531
+ mockFetch(buffer);
532
+
533
+ const result = await parseExcelFile("https://example.com/test.xlsx");
534
+
535
+
536
+ const cell = result.sheets[0].rows[0].cells[0];
537
+ expect(cell.style.backgroundColor).toBe("#FF0000");
538
+ });
539
+
540
+ test("extracts content field with plain text", async () => {
541
+ const workbook = new ExcelJS.Workbook();
542
+ const ws = workbook.addWorksheet("Content");
543
+
544
+ ws.getCell("A1").value = "Hello";
545
+
546
+ const buffer = await workbook.xlsx.writeBuffer();
547
+ mockFetch(buffer);
548
+
549
+ const result = await parseExcelFile("https://example.com/test.xlsx");
550
+
551
+
552
+ const cell = result.sheets[0].rows[0].cells[0];
553
+ expect(cell.content).toEqual({ type: "plain", text: "Hello" });
554
+ });
555
+
556
+ test("extracts rich text content with font properties", async () => {
557
+ const workbook = new ExcelJS.Workbook();
558
+ const ws = workbook.addWorksheet("RichText");
559
+
560
+ ws.getCell("A1").value = {
561
+ richText: [
562
+ { text: "Bold ", font: { bold: true } },
563
+ { text: "Red", font: { color: { argb: "FFFF0000" } } },
564
+ ],
565
+ };
566
+
567
+ const buffer = await workbook.xlsx.writeBuffer();
568
+ mockFetch(buffer);
569
+
570
+ const result = await parseExcelFile("https://example.com/test.xlsx");
571
+
572
+
573
+ const cell = result.sheets[0].rows[0].cells[0];
574
+ expect(cell.content.type).toBe("rich_text");
575
+ if (cell.content.type === "rich_text") {
576
+ expect(cell.content.parts).toHaveLength(2);
577
+ expect(cell.content.parts[0].text).toBe("Bold ");
578
+ expect(cell.content.parts[0].font?.bold).toBe(true);
579
+ expect(cell.content.parts[1].text).toBe("Red");
580
+ expect(cell.content.parts[1].font?.color).toBe("#FF0000");
581
+ }
582
+ });
583
+
584
+ test("extracts formula string", async () => {
585
+ const workbook = new ExcelJS.Workbook();
586
+ const ws = workbook.addWorksheet("Formulas");
587
+
588
+ ws.getCell("A1").value = 10;
589
+ ws.getCell("A2").value = 20;
590
+ ws.getCell("A3").value = { formula: "SUM(A1:A2)", result: 30 };
591
+
592
+ const buffer = await workbook.xlsx.writeBuffer();
593
+ mockFetch(buffer);
594
+
595
+ const result = await parseExcelFile("https://example.com/test.xlsx");
596
+
597
+
598
+ const cell = result.sheets[0].rows[2].cells[0];
599
+ expect(cell.formula).toBe("SUM(A1:A2)");
600
+ expect(cell.value).toBe("30");
601
+ });
602
+
603
+ test("extracts font name from cell style", async () => {
604
+ const workbook = new ExcelJS.Workbook();
605
+ const ws = workbook.addWorksheet("Fonts");
606
+
607
+ ws.getCell("A1").value = "Custom Font";
608
+ ws.getCell("A1").font = { name: "Courier New", size: 14 };
609
+
610
+ const buffer = await workbook.xlsx.writeBuffer();
611
+ mockFetch(buffer);
612
+
613
+ const result = await parseExcelFile("https://example.com/test.xlsx");
614
+
615
+
616
+ const cell = result.sheets[0].rows[0].cells[0];
617
+ expect(cell.style.fontName).toBe("Courier New");
618
+ expect(cell.style.fontSize).toBe(14);
619
+ });
620
+
621
+ test("extracts underline style", async () => {
622
+ const workbook = new ExcelJS.Workbook();
623
+ const ws = workbook.addWorksheet("Underline");
624
+
625
+ ws.getCell("A1").value = "Underlined";
626
+ ws.getCell("A1").font = { underline: true };
627
+ ws.getCell("A2").value = "Double";
628
+ ws.getCell("A2").font = { underline: "double" };
629
+
630
+ const buffer = await workbook.xlsx.writeBuffer();
631
+ mockFetch(buffer);
632
+
633
+ const result = await parseExcelFile("https://example.com/test.xlsx");
634
+
635
+
636
+ expect(result.sheets[0].rows[0].cells[0].style.fontUnderline).toBe("single");
637
+ expect(result.sheets[0].rows[1].cells[0].style.fontUnderline).toBe("double");
638
+ });
639
+
640
+ test("extracts strikethrough style", async () => {
641
+ const workbook = new ExcelJS.Workbook();
642
+ const ws = workbook.addWorksheet("Strike");
643
+
644
+ ws.getCell("A1").value = "Struck";
645
+ ws.getCell("A1").font = { strike: true };
646
+
647
+ const buffer = await workbook.xlsx.writeBuffer();
648
+ mockFetch(buffer);
649
+
650
+ const result = await parseExcelFile("https://example.com/test.xlsx");
651
+
652
+
653
+ expect(result.sheets[0].rows[0].cells[0].style.fontStrike).toBe(true);
654
+ });
655
+
656
+ test("skips hidden rows", async () => {
657
+ const workbook = new ExcelJS.Workbook();
658
+ const ws = workbook.addWorksheet("HiddenRows");
659
+
660
+ ws.getCell("A1").value = "Visible";
661
+ ws.getCell("A2").value = "Hidden";
662
+ ws.getRow(2).hidden = true;
663
+ ws.getCell("A3").value = "Also Visible";
664
+
665
+ const buffer = await workbook.xlsx.writeBuffer();
666
+ mockFetch(buffer);
667
+
668
+ const result = await parseExcelFile("https://example.com/test.xlsx");
669
+
670
+
671
+ const rowIndices = result.sheets[0].rows.map((r) => r.index);
672
+ expect(rowIndices).toContain(1);
673
+ expect(rowIndices).not.toContain(2);
674
+ expect(rowIndices).toContain(3);
675
+ });
676
+
677
+ test("marks hidden columns", async () => {
678
+ const workbook = new ExcelJS.Workbook();
679
+ const ws = workbook.addWorksheet("HiddenCols");
680
+
681
+ ws.getCell("A1").value = "A";
682
+ ws.getCell("B1").value = "B";
683
+ ws.getColumn(2).hidden = true;
684
+
685
+ const buffer = await workbook.xlsx.writeBuffer();
686
+ mockFetch(buffer);
687
+
688
+ const result = await parseExcelFile("https://example.com/test.xlsx");
689
+
690
+
691
+ const col2 = result.sheets[0].columns.find((c) => c.index === 2);
692
+ expect(col2?.hidden).toBe(true);
693
+
694
+ const col1 = result.sheets[0].columns.find((c) => c.index === 1);
695
+ expect(col1?.hidden).toBe(false);
696
+ });
697
+
698
+ test("extracts freeze panes", async () => {
699
+ const workbook = new ExcelJS.Workbook();
700
+ const ws = workbook.addWorksheet("Frozen");
701
+
702
+ ws.getCell("A1").value = "Header";
703
+ ws.getCell("A2").value = "Data";
704
+ ws.views = [{ state: "frozen", xSplit: 1, ySplit: 1, topLeftCell: "B2", activeCell: "B2" }];
705
+
706
+ const buffer = await workbook.xlsx.writeBuffer();
707
+ mockFetch(buffer);
708
+
709
+ const result = await parseExcelFile("https://example.com/test.xlsx");
710
+
711
+
712
+ expect(result.sheets[0].freezePane).toEqual({
713
+ frozenRows: 1,
714
+ frozenCols: 1,
715
+ });
716
+ });
717
+
718
+ test("includes new sheet-level fields", async () => {
719
+ const workbook = new ExcelJS.Workbook();
720
+ const ws = workbook.addWorksheet("Fields");
721
+ ws.getCell("A1").value = "Test";
722
+
723
+ const buffer = await workbook.xlsx.writeBuffer();
724
+ mockFetch(buffer);
725
+
726
+ const result = await parseExcelFile("https://example.com/test.xlsx");
727
+
728
+
729
+ const sheet = result.sheets[0];
730
+ expect(sheet.defaultRowHeight).toBeGreaterThan(0);
731
+ expect(sheet.defaultColWidth).toBeGreaterThan(0);
732
+ expect(typeof sheet.showGridLines).toBe("boolean");
733
+ expect(sheet.conditionalFormats).toEqual([]);
734
+ expect(sheet.images).toEqual([]);
735
+ expect(sheet.rowOutlineLevels).toBeInstanceOf(Map);
736
+ expect(sheet.colOutlineLevels).toBeInstanceOf(Map);
737
+ });
738
+
739
+ test("extracts hyperlink content", async () => {
740
+ const workbook = new ExcelJS.Workbook();
741
+ const ws = workbook.addWorksheet("Links");
742
+
743
+ ws.getCell("A1").value = {
744
+ text: "Google",
745
+ hyperlink: "https://google.com",
746
+ } as unknown as ExcelJS.CellHyperlinkValue;
747
+
748
+ const buffer = await workbook.xlsx.writeBuffer();
749
+ mockFetch(buffer);
750
+
751
+ const result = await parseExcelFile("https://example.com/test.xlsx");
752
+
753
+
754
+ const cell = result.sheets[0].rows[0].cells[0];
755
+ expect(cell.content.type).toBe("hyperlink");
756
+ if (cell.content.type === "hyperlink") {
757
+ expect(cell.content.text).toBe("Google");
758
+ expect(cell.content.url).toBe("https://google.com");
759
+ }
760
+ });
761
+ });
762
+
763
+ // =============================================================================
764
+ // Pattern fills
765
+ // =============================================================================
766
+
767
+ describe("getPatternFillCSS", () => {
768
+ test("returns CSS for darkVertical pattern", () => {
769
+ const result = getPatternFillCSS("darkVertical", "black", "white");
770
+ expect(result).toBeDefined();
771
+ expect(result).toContain("repeating-linear-gradient");
772
+ });
773
+
774
+ test("returns CSS for lightGrid pattern", () => {
775
+ const result = getPatternFillCSS("lightGrid", "black", "white");
776
+ expect(result).toBeDefined();
777
+ expect(result).toContain("repeating-linear-gradient");
778
+ });
779
+
780
+ test("returns CSS for gray125 pattern", () => {
781
+ const result = getPatternFillCSS("gray125", "#333", "#fff");
782
+ expect(result).toBeDefined();
783
+ });
784
+
785
+ test("returns undefined for unknown pattern", () => {
786
+ expect(getPatternFillCSS("unknownPattern", "black", "white")).toBeUndefined();
787
+ });
788
+ });
789
+
790
+ // =============================================================================
791
+ // CF icons
792
+ // =============================================================================
793
+
794
+ describe("getCfIcon", () => {
795
+ test("returns SVG data URI for valid icon set and index", () => {
796
+ const icon = getCfIcon("3TrafficLights1", 0);
797
+ expect(icon).toBeDefined();
798
+ expect(icon).toContain("data:image/svg+xml");
799
+ });
800
+
801
+ test("returns different icons for different indices", () => {
802
+ const icon0 = getCfIcon("3Arrows", 0);
803
+ const icon1 = getCfIcon("3Arrows", 1);
804
+ const icon2 = getCfIcon("3Arrows", 2);
805
+ expect(icon0).toBeDefined();
806
+ expect(icon1).toBeDefined();
807
+ expect(icon2).toBeDefined();
808
+ expect(icon0).not.toBe(icon1);
809
+ expect(icon1).not.toBe(icon2);
810
+ });
811
+
812
+ test("returns undefined for invalid icon set", () => {
813
+ expect(getCfIcon("nonexistent", 0)).toBeUndefined();
814
+ });
815
+
816
+ test("returns undefined for out-of-range index", () => {
817
+ expect(getCfIcon("3Arrows", 5)).toBeUndefined();
818
+ });
819
+
820
+ test("supports 4-icon and 5-icon sets", () => {
821
+ expect(getCfIcon("4Arrows", 3)).toBeDefined();
822
+ expect(getCfIcon("5Arrows", 4)).toBeDefined();
823
+ });
824
+ });
825
+
826
+ // =============================================================================
827
+ // CF evaluation
828
+ // =============================================================================
829
+
830
+ describe("evaluateConditionalFormats", () => {
831
+ function makeRows(values: number[]): ParsedRow[] {
832
+ return values.map((v, i) => ({
833
+ index: i + 1,
834
+ height: 20,
835
+ hidden: false,
836
+ cells: [{
837
+ column: 1,
838
+ value: String(v),
839
+ content: { type: "plain" as const, text: String(v) },
840
+ style: {},
841
+ }],
842
+ }));
843
+ }
844
+
845
+ test("evaluates cellIs greaterThan rule", () => {
846
+ const rows = makeRows([10, 50, 100]);
847
+ const cfs: ParsedConditionalFormat[] = [{
848
+ ref: "A1:A3",
849
+ rules: [{
850
+ ruleType: "style",
851
+ priority: 1,
852
+ type: "cellIs",
853
+ operator: "greaterThan",
854
+ formulae: [50],
855
+ style: { backgroundColor: "#FF0000" },
856
+ }],
857
+ }];
858
+
859
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
860
+
861
+ expect(result.has("3:1")).toBe(true);
862
+ expect(result.get("3:1")?.backgroundColor).toBe("#FF0000");
863
+ expect(result.has("1:1")).toBe(false);
864
+ expect(result.has("2:1")).toBe(false);
865
+ });
866
+
867
+ test("evaluates containsText rule", () => {
868
+ const rows: ParsedRow[] = [
869
+ { index: 1, height: 20, hidden: false, cells: [{ column: 1, value: "Apple Pie", content: { type: "plain", text: "Apple Pie" }, style: {} }] },
870
+ { index: 2, height: 20, hidden: false, cells: [{ column: 1, value: "Banana", content: { type: "plain", text: "Banana" }, style: {} }] },
871
+ ];
872
+
873
+ const cfs: ParsedConditionalFormat[] = [{
874
+ ref: "A1:A2",
875
+ rules: [{
876
+ ruleType: "style",
877
+ priority: 1,
878
+ type: "containsText",
879
+ operator: "containsText",
880
+ text: "apple",
881
+ style: { fontBold: true },
882
+ }],
883
+ }];
884
+
885
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
886
+ expect(result.get("1:1")?.fontBold).toBe(true);
887
+ expect(result.has("2:1")).toBe(false);
888
+ });
889
+
890
+ test("evaluates top10 rule", () => {
891
+ const rows = makeRows([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
892
+ const cfs: ParsedConditionalFormat[] = [{
893
+ ref: "A1:A10",
894
+ rules: [{
895
+ ruleType: "style",
896
+ priority: 1,
897
+ type: "top10",
898
+ rank: 3,
899
+ style: { backgroundColor: "#00FF00" },
900
+ }],
901
+ }];
902
+
903
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
904
+
905
+ expect(result.has("8:1")).toBe(true);
906
+ expect(result.has("9:1")).toBe(true);
907
+ expect(result.has("10:1")).toBe(true);
908
+ expect(result.has("1:1")).toBe(false);
909
+ });
910
+
911
+ test("evaluates aboveAverage rule", () => {
912
+ const rows = makeRows([10, 20, 30, 40, 50]);
913
+ const cfs: ParsedConditionalFormat[] = [{
914
+ ref: "A1:A5",
915
+ rules: [{
916
+ ruleType: "style",
917
+ priority: 1,
918
+ type: "aboveAverage",
919
+ aboveAverage: true,
920
+ style: { fontColor: "#008000" },
921
+ }],
922
+ }];
923
+
924
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
925
+
926
+ expect(result.has("4:1")).toBe(true);
927
+ expect(result.has("5:1")).toBe(true);
928
+ expect(result.has("3:1")).toBe(false);
929
+ });
930
+
931
+ test("evaluates color scale rule", () => {
932
+ const rows = makeRows([0, 50, 100]);
933
+ const cfs: ParsedConditionalFormat[] = [{
934
+ ref: "A1:A3",
935
+ rules: [{
936
+ ruleType: "colorScale",
937
+ priority: 1,
938
+ colors: ["#FF0000", "#00FF00"],
939
+ }],
940
+ }];
941
+
942
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
943
+
944
+ expect(result.get("1:1")?.backgroundColor).toBeDefined();
945
+ expect(result.get("2:1")?.backgroundColor).toBeDefined();
946
+ expect(result.get("3:1")?.backgroundColor).toBeDefined();
947
+
948
+ expect(result.get("1:1")?.backgroundColor).toBe("#FF0000");
949
+ expect(result.get("3:1")?.backgroundColor).toBe("#00FF00");
950
+ });
951
+
952
+ test("evaluates data bar rule", () => {
953
+ const rows = makeRows([10, 50, 100]);
954
+ const cfs: ParsedConditionalFormat[] = [{
955
+ ref: "A1:A3",
956
+ rules: [{
957
+ ruleType: "dataBar",
958
+ priority: 1,
959
+ color: "#638EC6",
960
+ minLength: 10,
961
+ maxLength: 90,
962
+ showValue: true,
963
+ }],
964
+ }];
965
+
966
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
967
+
968
+ expect(result.get("1:1")?.dataBarWidth).toBeDefined();
969
+ expect(result.get("3:1")?.dataBarWidth).toBeDefined();
970
+ expect(result.get("1:1")!.dataBarWidth).toBeLessThan(result.get("3:1")!.dataBarWidth!);
971
+ });
972
+
973
+ test("evaluates icon set rule", () => {
974
+ const rows = makeRows([10, 50, 90]);
975
+ const cfs: ParsedConditionalFormat[] = [{
976
+ ref: "A1:A3",
977
+ rules: [{
978
+ ruleType: "iconSet",
979
+ priority: 1,
980
+ iconSet: "3Arrows",
981
+ thresholds: [],
982
+ showValue: true,
983
+ reverse: false,
984
+ }],
985
+ }];
986
+
987
+ const result = evaluateConditionalFormats(cfs, rows, getCfIcon);
988
+
989
+ expect(result.get("1:1")?.iconSvg).toBeDefined();
990
+ expect(result.get("3:1")?.iconSvg).toBeDefined();
991
+ expect(result.get("1:1")?.iconSvg).not.toBe(result.get("3:1")?.iconSvg);
992
+ });
993
+ });
994
+
995
+ // =============================================================================
996
+ // CSV parser backward compat
997
+ // =============================================================================
998
+
999
+ describe("csv parser backward compat", () => {
1000
+ test("CSV cells have content field", () => {
1001
+ const result = parseCsvText("A,B\n1,2");
1002
+ const cell = result.rows[0].cells[0];
1003
+ expect(cell.content).toEqual({ type: "plain", text: "A" });
1004
+ });
1005
+
1006
+ test("CSV rows have hidden field", () => {
1007
+ const result = parseCsvText("A\n1");
1008
+ expect(result.rows[0].hidden).toBe(false);
1009
+ });
1010
+
1011
+ test("CSV columns have hidden field", () => {
1012
+ const result = parseCsvText("A,B\n1,2");
1013
+ expect(result.columns[0].hidden).toBe(false);
1014
+ });
1015
+
1016
+ test("CSV sheet has new fields", () => {
1017
+ const result = parseCsvText("A\n1");
1018
+ expect(result.defaultRowHeight).toBe(20);
1019
+ expect(result.defaultColWidth).toBe(8);
1020
+ expect(result.showGridLines).toBe(true);
1021
+ expect(result.conditionalFormats).toEqual([]);
1022
+ expect(result.images).toEqual([]);
1023
+ });
1024
+ });
1025
+
1026
+ // =============================================================================
1027
+ // Gradient fills
1028
+ // =============================================================================
1029
+
1030
+ describe("gradient fills", () => {
1031
+ const originalFetch = globalThis.fetch;
1032
+ afterEach(() => { globalThis.fetch = originalFetch; });
1033
+
1034
+ test("extracts linear gradient from angle fill", async () => {
1035
+ const workbook = new ExcelJS.Workbook();
1036
+ const ws = workbook.addWorksheet("Gradient");
1037
+
1038
+ ws.getCell("A1").value = "Gradient";
1039
+ ws.getCell("A1").fill = {
1040
+ type: "gradient",
1041
+ gradient: "angle",
1042
+ degree: 90,
1043
+ stops: [
1044
+ { position: 0, color: { argb: "FFFF0000" } },
1045
+ { position: 1, color: { argb: "FF0000FF" } },
1046
+ ],
1047
+ };
1048
+
1049
+ const buffer = await workbook.xlsx.writeBuffer();
1050
+ globalThis.fetch = vi.fn().mockResolvedValue({
1051
+ ok: true,
1052
+ arrayBuffer: () => Promise.resolve(buffer),
1053
+ });
1054
+
1055
+ const result = await parseExcelFile("https://example.com/test.xlsx");
1056
+ const cell = result.sheets[0].rows[0].cells[0];
1057
+ expect(cell.style.backgroundGradient).toBeDefined();
1058
+ expect(cell.style.backgroundGradient).toContain("linear-gradient");
1059
+ });
1060
+ });
1061
+