@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,556 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type { CellStyle, BorderStyle, GradientData } from "./types";
3
+ import type { ThemeData, OoxmlColor } from "./ooxml_color";
4
+ import { parseColorElement, resolveColor, prefixHex, DEFAULT_INDEXED_COLORS } from "./ooxml_color";
5
+ import { getPatternFillCSS } from "./excel_pattern_fills";
6
+ import { decodeXmlEntities } from "./xml_entities";
7
+
8
+ // =============================================================================
9
+ // Types
10
+ // =============================================================================
11
+
12
+ export interface StyleResolver {
13
+ resolveStyle(xfIndex: number): CellStyle;
14
+ getNumFmt(xfIndex: number): string;
15
+ indexedColors: string[];
16
+ }
17
+
18
+ interface FontEntry {
19
+ bold: boolean;
20
+ italic: boolean;
21
+ strike: boolean;
22
+ underline?: "single" | "double";
23
+ vertAlign?: "superscript" | "subscript";
24
+ size?: number;
25
+ name?: string;
26
+ scheme?: string;
27
+ color?: OoxmlColor;
28
+ }
29
+
30
+ interface FillEntry {
31
+ patternType: string;
32
+ fgColor?: OoxmlColor;
33
+ bgColor?: OoxmlColor;
34
+ // Gradient fills
35
+ gradientType?: string;
36
+ gradientDegree?: number;
37
+ gradientStops?: Array<{ position: number; color: OoxmlColor }>;
38
+ }
39
+
40
+ interface BorderEntry {
41
+ left?: BorderSide;
42
+ right?: BorderSide;
43
+ top?: BorderSide;
44
+ bottom?: BorderSide;
45
+ diagonal?: BorderSide;
46
+ diagonalUp?: boolean;
47
+ diagonalDown?: boolean;
48
+ }
49
+
50
+ interface BorderSide {
51
+ style?: string;
52
+ color?: OoxmlColor;
53
+ }
54
+
55
+ interface XfEntry {
56
+ numFmtId: number;
57
+ fontId: number;
58
+ fillId: number;
59
+ borderId: number;
60
+ applyFont: boolean;
61
+ applyFill: boolean;
62
+ applyBorder: boolean;
63
+ applyAlignment: boolean;
64
+ applyNumFmt: boolean;
65
+ horizontalAlign?: string;
66
+ verticalAlign?: string;
67
+ wrapText: boolean;
68
+ indent: number;
69
+ textRotation: number;
70
+ shrinkToFit: boolean;
71
+ readingOrder: number;
72
+ }
73
+
74
+ // =============================================================================
75
+ // Built-in number formats (IDs 0-49 are implicit in Excel)
76
+ // =============================================================================
77
+
78
+ const BUILTIN_NUM_FMTS: Record<number, string> = {
79
+ 0: "General",
80
+ 1: "0",
81
+ 2: "0.00",
82
+ 3: "#,##0",
83
+ 4: "#,##0.00",
84
+ 9: "0%",
85
+ 10: "0.00%",
86
+ 11: "0.00E+00",
87
+ 12: "# ?/?",
88
+ 13: "# ??/??",
89
+ 14: "mm-dd-yy",
90
+ 15: "d-mmm-yy",
91
+ 16: "d-mmm",
92
+ 17: "mmm-yy",
93
+ 18: "h:mm AM/PM",
94
+ 19: "h:mm:ss AM/PM",
95
+ 20: "h:mm",
96
+ 21: "h:mm:ss",
97
+ 22: "m/d/yy h:mm",
98
+ 37: "#,##0 ;(#,##0)",
99
+ 38: "#,##0 ;[Red](#,##0)",
100
+ 39: "#,##0.00;(#,##0.00)",
101
+ 40: "#,##0.00;[Red](#,##0.00)",
102
+ 45: "mm:ss",
103
+ 46: "[h]:mm:ss",
104
+ 47: "mmss.0",
105
+ 48: "##0.0E+0",
106
+ 49: "@",
107
+ };
108
+
109
+ // =============================================================================
110
+ // XML parser
111
+ // =============================================================================
112
+
113
+ const xmlParser = new XMLParser({
114
+ ignoreAttributes: false,
115
+ attributeNamePrefix: "@_",
116
+ processEntities: false,
117
+ isArray: (tagName) =>
118
+ tagName === "numFmt" ||
119
+ tagName === "font" ||
120
+ tagName === "fill" ||
121
+ tagName === "border" ||
122
+ tagName === "xf" ||
123
+ tagName === "rgbColor" ||
124
+ tagName === "stop",
125
+ });
126
+
127
+ // =============================================================================
128
+ // Main parse function
129
+ // =============================================================================
130
+
131
+ export function parseStyles(stylesXml: string, theme: ThemeData): StyleResolver {
132
+ const doc = xmlParser.parse(stylesXml);
133
+ const root = doc?.["styleSheet"];
134
+ if (!root) {
135
+ return emptyStyleResolver();
136
+ }
137
+
138
+ // Custom indexed colors (override defaults)
139
+ const indexedColors = parseIndexedColors(root);
140
+
141
+ // Number formats
142
+ const customFmts = parseNumFmts(root);
143
+
144
+ // Fonts
145
+ const fonts = parseFonts(root);
146
+
147
+ // Fills
148
+ const fills = parseFills(root);
149
+
150
+ // Borders
151
+ const borders = parseBorders(root);
152
+
153
+ // Cell XFs
154
+ const cellXfs = parseCellXfs(root);
155
+
156
+ function resolveStyle(xfIndex: number): CellStyle {
157
+ if (xfIndex < 0 || xfIndex >= cellXfs.length) return {};
158
+ const xf = cellXfs[xfIndex];
159
+ const style: CellStyle = {};
160
+
161
+ // Font
162
+ if (xf.applyFont && xf.fontId >= 0 && xf.fontId < fonts.length) {
163
+ const font = fonts[xf.fontId];
164
+ if (font.bold) style.fontBold = true;
165
+ if (font.italic) style.fontItalic = true;
166
+ if (font.strike) style.fontStrike = true;
167
+ if (font.underline) style.fontUnderline = font.underline;
168
+ if (font.vertAlign) style.fontVertAlign = font.vertAlign;
169
+ if (font.size) style.fontSize = font.size;
170
+
171
+ // Font name: resolve scheme to theme font if present
172
+ if (font.scheme === "minor") {
173
+ style.fontName = theme.minorFont;
174
+ } else if (font.scheme === "major") {
175
+ style.fontName = theme.majorFont;
176
+ } else if (font.name) {
177
+ style.fontName = font.name;
178
+ }
179
+
180
+ // Font color
181
+ const fontColor = resolveColor(font.color, theme, indexedColors, "font");
182
+ if (fontColor) style.fontColor = prefixHex(fontColor);
183
+ }
184
+
185
+ // Fill
186
+ if (xf.applyFill && xf.fillId >= 0 && xf.fillId < fills.length) {
187
+ const fill = fills[xf.fillId];
188
+ if (fill.gradientType) {
189
+ const gradientCss = buildGradientCss(fill, theme, indexedColors);
190
+ if (gradientCss) style.backgroundGradient = gradientCss;
191
+ // Populate structured gradient data for canvas rendering
192
+ const gradData = buildGradientData(fill, theme, indexedColors);
193
+ if (gradData) style.gradientData = gradData;
194
+ } else if (fill.patternType === "solid") {
195
+ const fgHex = resolveColor(fill.fgColor, theme, indexedColors, "fill");
196
+ if (fgHex) style.backgroundColor = prefixHex(fgHex);
197
+ } else if (fill.patternType && fill.patternType !== "none") {
198
+ const fgHex = resolveColor(fill.fgColor, theme, indexedColors, "fill");
199
+ const bgHex = resolveColor(fill.bgColor, theme, indexedColors, "fill");
200
+ const fg = fgHex ? prefixHex(fgHex) : "#000000";
201
+ const bg = bgHex ? prefixHex(bgHex) : "#FFFFFF";
202
+ const patternCss = getPatternFillCSS(fill.patternType, fg, bg);
203
+ if (patternCss) {
204
+ style.backgroundPattern = patternCss;
205
+ style.patternType = fill.patternType;
206
+ }
207
+ }
208
+ }
209
+
210
+ // Alignment
211
+ if (xf.applyAlignment) {
212
+ if (xf.horizontalAlign === "left" || xf.horizontalAlign === "center" || xf.horizontalAlign === "right") {
213
+ style.horizontalAlign = xf.horizontalAlign;
214
+ }
215
+ if (xf.verticalAlign === "top" || xf.verticalAlign === "bottom") {
216
+ style.verticalAlign = xf.verticalAlign;
217
+ } else if (xf.verticalAlign === "center") {
218
+ style.verticalAlign = "middle";
219
+ }
220
+ if (xf.wrapText) style.wrapText = true;
221
+ if (xf.indent > 0) style.indent = xf.indent;
222
+ if (xf.textRotation !== 0) {
223
+ if (xf.textRotation === 255) {
224
+ style.textRotation = "vertical";
225
+ } else {
226
+ style.textRotation = xf.textRotation;
227
+ }
228
+ }
229
+ if (xf.shrinkToFit) style.shrinkToFit = true;
230
+ if (xf.readingOrder > 0) style.readingOrder = xf.readingOrder;
231
+ }
232
+
233
+ // Borders
234
+ if (xf.applyBorder && xf.borderId >= 0 && xf.borderId < borders.length) {
235
+ const border = borders[xf.borderId];
236
+ if (border.top?.style) style.borderTop = mapBorderStyle(border.top, theme, indexedColors);
237
+ if (border.right?.style) style.borderRight = mapBorderStyle(border.right, theme, indexedColors);
238
+ if (border.bottom?.style) style.borderBottom = mapBorderStyle(border.bottom, theme, indexedColors);
239
+ if (border.left?.style) style.borderLeft = mapBorderStyle(border.left, theme, indexedColors);
240
+ if (border.diagonal?.style) {
241
+ style.borderDiagonal = mapBorderStyle(border.diagonal, theme, indexedColors);
242
+ if (border.diagonalUp) style.diagonalUp = true;
243
+ if (border.diagonalDown) style.diagonalDown = true;
244
+ }
245
+ }
246
+
247
+ return style;
248
+ }
249
+
250
+ function getNumFmt(xfIndex: number): string {
251
+ if (xfIndex < 0 || xfIndex >= cellXfs.length) return "General";
252
+ const fmtId = cellXfs[xfIndex].numFmtId;
253
+ if (!cellXfs[xfIndex].applyNumFmt && fmtId === 0) return "General";
254
+ return customFmts.get(fmtId) ?? BUILTIN_NUM_FMTS[fmtId] ?? "General";
255
+ }
256
+
257
+ return { resolveStyle, getNumFmt, indexedColors };
258
+ }
259
+
260
+ // =============================================================================
261
+ // Parsing sub-functions
262
+ // =============================================================================
263
+
264
+ function parseIndexedColors(root: Record<string, unknown>): string[] {
265
+ const result = [...DEFAULT_INDEXED_COLORS];
266
+ const colorsEl = root["colors"] as Record<string, unknown> | undefined;
267
+ if (!colorsEl) return result;
268
+
269
+ const indexed = colorsEl["indexedColors"] as Record<string, unknown> | undefined;
270
+ if (!indexed) return result;
271
+
272
+ const rgbColors = indexed["rgbColor"] as Array<Record<string, string>> | undefined;
273
+ if (!rgbColors) return result;
274
+
275
+ for (let i = 0; i < rgbColors.length; i++) {
276
+ const rgb = rgbColors[i]["@_rgb"];
277
+ if (rgb) {
278
+ // AARRGGBB → RRGGBB
279
+ result[i] = rgb.length === 8 ? rgb.slice(2) : rgb;
280
+ }
281
+ }
282
+
283
+ return result;
284
+ }
285
+
286
+ function parseNumFmts(root: Record<string, unknown>): Map<number, string> {
287
+ const map = new Map<number, string>();
288
+ const numFmtsEl = root["numFmts"] as Record<string, unknown> | undefined;
289
+ if (!numFmtsEl) return map;
290
+
291
+ const fmts = numFmtsEl["numFmt"] as Array<Record<string, string>> | undefined;
292
+ if (!fmts) return map;
293
+
294
+ for (const fmt of fmts) {
295
+ const id = parseInt(fmt["@_numFmtId"], 10);
296
+ const code = fmt["@_formatCode"];
297
+ if (!isNaN(id) && code) {
298
+ map.set(id, decodeXmlEntities(code));
299
+ }
300
+ }
301
+
302
+ return map;
303
+ }
304
+
305
+ function parseFonts(root: Record<string, unknown>): FontEntry[] {
306
+ const fontsEl = root["fonts"] as Record<string, unknown> | undefined;
307
+ if (!fontsEl) return [];
308
+
309
+ const fontArr = fontsEl["font"] as Array<Record<string, unknown>> | undefined;
310
+ if (!fontArr) return [];
311
+
312
+ return fontArr.map(parseFont);
313
+ }
314
+
315
+ function parseFont(el: Record<string, unknown>): FontEntry {
316
+ const entry: FontEntry = {
317
+ bold: el["b"] !== undefined,
318
+ italic: el["i"] !== undefined,
319
+ strike: el["strike"] !== undefined,
320
+ };
321
+
322
+ const u = el["u"] as Record<string, string> | string | undefined;
323
+ if (u !== undefined) {
324
+ if (typeof u === "object" && u["@_val"] === "double") {
325
+ entry.underline = "double";
326
+ } else {
327
+ entry.underline = "single";
328
+ }
329
+ }
330
+
331
+ const va = el["vertAlign"] as Record<string, string> | undefined;
332
+ if (va?.["@_val"] === "superscript" || va?.["@_val"] === "subscript") {
333
+ entry.vertAlign = va["@_val"];
334
+ }
335
+
336
+ const sz = el["sz"] as Record<string, string> | undefined;
337
+ if (sz?.["@_val"]) entry.size = parseFloat(sz["@_val"]);
338
+
339
+ const name = el["name"] as Record<string, string> | undefined;
340
+ if (name?.["@_val"]) entry.name = name["@_val"];
341
+
342
+ const scheme = el["scheme"] as Record<string, string> | undefined;
343
+ if (scheme?.["@_val"]) entry.scheme = scheme["@_val"];
344
+
345
+ const color = el["color"] as Record<string, string> | undefined;
346
+ if (color) entry.color = parseColorElement(color);
347
+
348
+ return entry;
349
+ }
350
+
351
+ function parseFills(root: Record<string, unknown>): FillEntry[] {
352
+ const fillsEl = root["fills"] as Record<string, unknown> | undefined;
353
+ if (!fillsEl) return [];
354
+
355
+ const fillArr = fillsEl["fill"] as Array<Record<string, unknown>> | undefined;
356
+ if (!fillArr) return [];
357
+
358
+ return fillArr.map(parseFill);
359
+ }
360
+
361
+ function parseFill(el: Record<string, unknown>): FillEntry {
362
+ // Gradient fill
363
+ const gradientFill = el["gradientFill"] as Record<string, unknown> | undefined;
364
+ if (gradientFill) {
365
+ return parseGradientFill(gradientFill);
366
+ }
367
+
368
+ // Pattern fill
369
+ const patternFill = el["patternFill"] as Record<string, unknown> | undefined;
370
+ if (!patternFill) return { patternType: "none" };
371
+
372
+ const patternType = (patternFill["@_patternType"] as string) ?? "none";
373
+ const fgColor = patternFill["fgColor"] as Record<string, string> | undefined;
374
+ const bgColor = patternFill["bgColor"] as Record<string, string> | undefined;
375
+
376
+ return {
377
+ patternType,
378
+ fgColor: fgColor ? parseColorElement(fgColor) : undefined,
379
+ bgColor: bgColor ? parseColorElement(bgColor) : undefined,
380
+ };
381
+ }
382
+
383
+ function parseGradientFill(el: Record<string, unknown>): FillEntry {
384
+ const type = (el["@_type"] as string) ?? "linear";
385
+ const degree = el["@_degree"] !== undefined ? parseFloat(el["@_degree"] as string) : 0;
386
+
387
+ const stopArr = el["stop"] as Array<Record<string, unknown>> | undefined;
388
+ const stops: Array<{ position: number; color: OoxmlColor }> = [];
389
+ if (stopArr) {
390
+ for (const stop of stopArr) {
391
+ const position = parseFloat(stop["@_position"] as string);
392
+ const colorEl = stop["color"] as Record<string, string> | undefined;
393
+ if (colorEl && !isNaN(position)) {
394
+ stops.push({ position, color: parseColorElement(colorEl) });
395
+ }
396
+ }
397
+ }
398
+
399
+ return {
400
+ patternType: "none",
401
+ gradientType: type,
402
+ gradientDegree: degree,
403
+ gradientStops: stops,
404
+ };
405
+ }
406
+
407
+ function parseBorders(root: Record<string, unknown>): BorderEntry[] {
408
+ const bordersEl = root["borders"] as Record<string, unknown> | undefined;
409
+ if (!bordersEl) return [];
410
+
411
+ const borderArr = bordersEl["border"] as Array<Record<string, unknown>> | undefined;
412
+ if (!borderArr) return [];
413
+
414
+ return borderArr.map(parseBorder);
415
+ }
416
+
417
+ function parseBorder(el: Record<string, unknown>): BorderEntry {
418
+ return {
419
+ left: parseBorderSide(el["left"] as Record<string, unknown> | undefined),
420
+ right: parseBorderSide(el["right"] as Record<string, unknown> | undefined),
421
+ top: parseBorderSide(el["top"] as Record<string, unknown> | undefined),
422
+ bottom: parseBorderSide(el["bottom"] as Record<string, unknown> | undefined),
423
+ diagonal: parseBorderSide(el["diagonal"] as Record<string, unknown> | undefined),
424
+ diagonalUp: el["@_diagonalUp"] === "1" || el["@_diagonalUp"] === "true",
425
+ diagonalDown: el["@_diagonalDown"] === "1" || el["@_diagonalDown"] === "true",
426
+ };
427
+ }
428
+
429
+ function parseBorderSide(el: Record<string, unknown> | undefined): BorderSide | undefined {
430
+ if (!el) return undefined;
431
+ const style = el["@_style"] as string | undefined;
432
+ if (!style) return undefined;
433
+
434
+ const colorEl = el["color"] as Record<string, string> | undefined;
435
+ return {
436
+ style,
437
+ color: colorEl ? parseColorElement(colorEl) : undefined,
438
+ };
439
+ }
440
+
441
+ function parseCellXfs(root: Record<string, unknown>): XfEntry[] {
442
+ const xfsEl = root["cellXfs"] as Record<string, unknown> | undefined;
443
+ if (!xfsEl) return [];
444
+
445
+ const xfArr = xfsEl["xf"] as Array<Record<string, unknown>> | undefined;
446
+ if (!xfArr) return [];
447
+
448
+ return xfArr.map(parseXf);
449
+ }
450
+
451
+ function parseXf(el: Record<string, unknown>): XfEntry {
452
+ const alignment = el["alignment"] as Record<string, string> | undefined;
453
+ return {
454
+ numFmtId: parseInt((el["@_numFmtId"] as string) ?? "0", 10),
455
+ fontId: parseInt((el["@_fontId"] as string) ?? "0", 10),
456
+ fillId: parseInt((el["@_fillId"] as string) ?? "0", 10),
457
+ borderId: parseInt((el["@_borderId"] as string) ?? "0", 10),
458
+ applyFont: el["@_applyFont"] === "1" || el["@_applyFont"] === "true" || el["@_fontId"] !== undefined,
459
+ applyFill: el["@_applyFill"] === "1" || el["@_applyFill"] === "true" || el["@_fillId"] !== undefined,
460
+ applyBorder: el["@_applyBorder"] === "1" || el["@_applyBorder"] === "true" || el["@_borderId"] !== undefined,
461
+ applyAlignment: el["@_applyAlignment"] === "1" || el["@_applyAlignment"] === "true" || alignment !== undefined,
462
+ applyNumFmt: el["@_applyNumberFormat"] === "1" || el["@_applyNumberFormat"] === "true" || (parseInt((el["@_numFmtId"] as string) ?? "0", 10) !== 0),
463
+ horizontalAlign: alignment?.["@_horizontal"],
464
+ verticalAlign: alignment?.["@_vertical"],
465
+ wrapText: alignment?.["@_wrapText"] === "1" || alignment?.["@_wrapText"] === "true",
466
+ indent: parseInt(alignment?.["@_indent"] ?? "0", 10),
467
+ textRotation: parseInt(alignment?.["@_textRotation"] ?? "0", 10),
468
+ shrinkToFit: alignment?.["@_shrinkToFit"] === "1" || alignment?.["@_shrinkToFit"] === "true",
469
+ readingOrder: parseInt(alignment?.["@_readingOrder"] ?? "0", 10),
470
+ };
471
+ }
472
+
473
+ // =============================================================================
474
+ // Border style mapping
475
+ // =============================================================================
476
+
477
+ function mapBorderStyle(side: BorderSide, theme: ThemeData, indexedColors: string[]): BorderStyle {
478
+ const colorHex = resolveColor(side.color, theme, indexedColors, "border");
479
+ const colorCss = colorHex ? prefixHex(colorHex) : undefined;
480
+
481
+ switch (side.style) {
482
+ case "thin":
483
+ return { width: 1, style: "solid", color: colorCss };
484
+ case "medium":
485
+ return { width: 2, style: "solid", color: colorCss };
486
+ case "thick":
487
+ return { width: 3, style: "solid", color: colorCss };
488
+ case "dotted":
489
+ return { width: 1, style: "dotted", color: colorCss };
490
+ case "dashed":
491
+ case "dashDot":
492
+ case "dashDotDot":
493
+ case "mediumDashed":
494
+ case "mediumDashDot":
495
+ case "mediumDashDotDot":
496
+ return { width: 1, style: "dashed", color: colorCss };
497
+ case "double":
498
+ return { width: 3, style: "double", color: colorCss };
499
+ case "hair":
500
+ return { width: 1, style: "solid", color: colorCss };
501
+ default:
502
+ return { width: 1, style: "solid", color: colorCss };
503
+ }
504
+ }
505
+
506
+ // =============================================================================
507
+ // Gradient CSS
508
+ // =============================================================================
509
+
510
+ function buildGradientCss(fill: FillEntry, theme: ThemeData, indexedColors: string[]): string | undefined {
511
+ if (!fill.gradientStops || fill.gradientStops.length < 2) return undefined;
512
+
513
+ const cssStops = fill.gradientStops
514
+ .map((stop) => {
515
+ const hex = resolveColor(stop.color, theme, indexedColors, "fill");
516
+ const color = hex ? prefixHex(hex) : "transparent";
517
+ const position = Math.round(stop.position * 100);
518
+ return `${color} ${position}%`;
519
+ })
520
+ .join(", ");
521
+
522
+ if (fill.gradientType === "path") {
523
+ // Radial gradient — center at 50% 50% by default
524
+ return `radial-gradient(circle at 50% 50%, ${cssStops})`;
525
+ }
526
+
527
+ return `linear-gradient(${fill.gradientDegree ?? 0}deg, ${cssStops})`;
528
+ }
529
+
530
+ function buildGradientData(fill: FillEntry, theme: ThemeData, indexedColors: string[]): GradientData | undefined {
531
+ if (!fill.gradientStops || fill.gradientStops.length < 2) return undefined;
532
+
533
+ const stops = fill.gradientStops.map((stop) => {
534
+ const hex = resolveColor(stop.color, theme, indexedColors, "fill");
535
+ const color = hex ? prefixHex(hex) : "transparent";
536
+ return { position: stop.position, color };
537
+ });
538
+
539
+ return {
540
+ type: fill.gradientType === "path" ? "radial" : "linear",
541
+ degree: fill.gradientDegree ?? 0,
542
+ stops,
543
+ };
544
+ }
545
+
546
+ // =============================================================================
547
+ // Empty resolver for missing styles.xml
548
+ // =============================================================================
549
+
550
+ export function emptyStyleResolver(): StyleResolver {
551
+ return {
552
+ resolveStyle: () => ({}),
553
+ getNumFmt: () => "General",
554
+ indexedColors: [...DEFAULT_INDEXED_COLORS],
555
+ };
556
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { XMLParser } from "fast-xml-parser";
3
+ import { parseTableXml, extractTableRIds } from "./ooxml_table";
4
+
5
+ const parser = new XMLParser({
6
+ ignoreAttributes: false,
7
+ attributeNamePrefix: "@_",
8
+ isArray: (tagName) => tagName === "tableColumn" || tagName === "Relationship",
9
+ });
10
+ const parseXml = (xml: string) => parser.parse(xml) as Record<string, unknown>;
11
+
12
+ const TABLE_XML = `<?xml version="1.0"?>
13
+ <table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" name="Sales" displayName="Sales" ref="A1:C5" headerRowCount="1" totalsRowCount="0">
14
+ <autoFilter ref="A1:C5"/>
15
+ <tableColumns count="3">
16
+ <tableColumn id="1" name="Product"/>
17
+ <tableColumn id="2" name="Revenue"/>
18
+ <tableColumn id="3" name="Cost"/>
19
+ </tableColumns>
20
+ <tableStyleInfo name="TableStyleMedium2"/>
21
+ </table>`;
22
+
23
+ const TABLE_WITH_TOTALS_XML = `<?xml version="1.0"?>
24
+ <table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" name="Budget" displayName="Budget" ref="A1:B6" headerRowCount="1" totalsRowCount="1">
25
+ <tableColumns count="2">
26
+ <tableColumn id="1" name="Category"/>
27
+ <tableColumn id="2" name="Amount" totalsRowFunction="sum"/>
28
+ </tableColumns>
29
+ </table>`;
30
+
31
+ describe("parseTableXml", () => {
32
+ it("parses table name, ref, columns, and flags", () => {
33
+ const result = parseTableXml(TABLE_XML, parseXml);
34
+ expect(result.name).toBe("Sales");
35
+ expect(result.displayName).toBe("Sales");
36
+ expect(result.ref).toBe("A1:C5");
37
+ expect(result.headerRow).toBe(true);
38
+ expect(result.totalsRow).toBe(false);
39
+ expect(result.autoFilter).toBe(true);
40
+ expect(result.columns).toHaveLength(3);
41
+ expect(result.columns[0]).toEqual({ id: 1, name: "Product", totalsFunction: undefined, totalsFormula: undefined });
42
+ expect(result.columns[1]).toEqual({ id: 2, name: "Revenue", totalsFunction: undefined, totalsFormula: undefined });
43
+ expect(result.columns[2]).toEqual({ id: 3, name: "Cost", totalsFunction: undefined, totalsFormula: undefined });
44
+ });
45
+
46
+ it("extracts style name", () => {
47
+ const result = parseTableXml(TABLE_XML, parseXml);
48
+ expect(result.styleName).toBe("TableStyleMedium2");
49
+ });
50
+
51
+ it("parses table with totals function", () => {
52
+ const result = parseTableXml(TABLE_WITH_TOTALS_XML, parseXml);
53
+ expect(result.totalsRow).toBe(true);
54
+ expect(result.columns[1].totalsFunction).toBe("sum");
55
+ });
56
+ });
57
+
58
+ describe("extractTableRIds", () => {
59
+ it("extracts table relationship IDs", () => {
60
+ const relsXml = `<?xml version="1.0"?>
61
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
62
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/>
63
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>
64
+ </Relationships>`;
65
+ const doc = parseXml(relsXml);
66
+ const result = extractTableRIds(doc);
67
+ expect(result.size).toBe(1);
68
+ expect(result.get("rId1")).toBe("../tables/table1.xml");
69
+ });
70
+ });