@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,393 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { unzipXlsx, decodeUtf8 } from "./ooxml_zip";
3
+ import { parseTheme, defaultThemeColors } from "./ooxml_color";
4
+ import type { ThemeData } from "./ooxml_color";
5
+ import { parseStyles, emptyStyleResolver } from "./ooxml_styles";
6
+ import { parseWorkbook, parseWorkbookRels, parseSharedStrings } from "./ooxml_workbook";
7
+ import { parseSheet, emptySheet } from "./ooxml_sheet";
8
+ import { parseChartXml } from "./ooxml_chart";
9
+ import { parseDrawingXml } from "./ooxml_drawing";
10
+ import type { DrawingRelation } from "./ooxml_drawing";
11
+ import { parseTableXml, extractTableRIds } from "./ooxml_table";
12
+ import {
13
+ parsePivotCacheDefinitionXml,
14
+ parsePivotTableXml,
15
+ extractPivotTableRIds,
16
+ extractWorkbookPivotCaches,
17
+ } from "./ooxml_pivot";
18
+ import { parseVmlDrawing } from "./ooxml_shape";
19
+ import type { ParsedSpreadsheet, ParsedSheet, PrintTitles } from "./types";
20
+ import type { ParsedChart } from "./ooxml_chart_types";
21
+ import type { ParsedPivotCache, ParsedPivotTable } from "./ooxml_pivot_types";
22
+ import { colLettersToNum } from "./excel_utils";
23
+
24
+ // Re-export all public types from the canonical source
25
+ export type {
26
+ ParsedSpreadsheet,
27
+ ParsedSheet,
28
+ ParsedImage,
29
+ FreezePane,
30
+ ParsedColumn,
31
+ ParsedRow,
32
+ CellContent,
33
+ RichTextPart,
34
+ RichTextFont,
35
+ ParsedCell,
36
+ CellStyle,
37
+ BorderStyle,
38
+ MergedCellRange,
39
+ } from "./types";
40
+ export { PasswordProtectedError } from "./types";
41
+
42
+ export type {
43
+ ParsedConditionalFormat,
44
+ ParsedCfRule,
45
+ ParsedColorScaleRule,
46
+ ParsedDataBarRule,
47
+ ParsedIconSetRule,
48
+ ParsedStyleRule,
49
+ CfThreshold,
50
+ ParsedCfStyle,
51
+ } from "./excel_cf_types";
52
+
53
+ // =============================================================================
54
+ // Parsing
55
+ // =============================================================================
56
+
57
+ export async function parseExcelFile(
58
+ url: string,
59
+ signal?: AbortSignal,
60
+ ): Promise<ParsedSpreadsheet> {
61
+ const response = await fetch(url, { signal });
62
+ if (!response.ok) {
63
+ throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
64
+ }
65
+ return parseExcelBuffer(await response.arrayBuffer());
66
+ }
67
+
68
+ /**
69
+ * Parse an in-memory .xlsx buffer into a ParsedSpreadsheet.
70
+ * Use this when you already have the bytes (e.g. from File.arrayBuffer()).
71
+ */
72
+ export function parseExcelBuffer(arrayBuffer: ArrayBuffer): ParsedSpreadsheet {
73
+ const zip = unzipXlsx(arrayBuffer);
74
+ return parseExcelFromZip(zip);
75
+ }
76
+
77
+ /**
78
+ * Parse from already-unzipped entries. Use this when you need both the
79
+ * ParsedSpreadsheet and the raw ZIP entries (e.g. for delta export).
80
+ */
81
+ export function parseExcelFromZip(zip: Record<string, Uint8Array>): ParsedSpreadsheet {
82
+ // Parse theme
83
+ const themeEntry = findEntry(zip, "xl/theme/theme1.xml");
84
+ const theme: ThemeData = themeEntry
85
+ ? parseTheme(decodeUtf8(themeEntry))
86
+ : { colors: defaultThemeColors(), majorFont: "Calibri", minorFont: "Calibri" };
87
+
88
+ // Parse styles
89
+ const stylesEntry = findEntry(zip, "xl/styles.xml");
90
+ const styles = stylesEntry
91
+ ? parseStyles(decodeUtf8(stylesEntry), theme)
92
+ : emptyStyleResolver();
93
+
94
+ // Parse workbook
95
+ const wbEntry = findEntry(zip, "xl/workbook.xml");
96
+ if (!wbEntry) {
97
+ return { sheets: [emptySheet("Sheet1")], activeSheetIndex: 0 };
98
+ }
99
+ const workbookInfo = parseWorkbook(decodeUtf8(wbEntry));
100
+
101
+ // Parse workbook relationships
102
+ const wbRelsEntry = findEntry(zip, "xl/_rels/workbook.xml.rels");
103
+ const wbRels = wbRelsEntry ? parseWorkbookRels(decodeUtf8(wbRelsEntry)) : new Map<string, string>();
104
+
105
+ // Parse shared strings
106
+ const ssEntry = findEntry(zip, "xl/sharedStrings.xml");
107
+ const sharedStrings = ssEntry
108
+ ? parseSharedStrings(decodeUtf8(ssEntry), theme, styles.indexedColors)
109
+ : [];
110
+
111
+ // Generic XML parser for chart/drawing/table/comment files
112
+ const xmlParser = new XMLParser({
113
+ ignoreAttributes: false,
114
+ attributeNamePrefix: "@_",
115
+ processEntities: false,
116
+ isArray: (tagName) =>
117
+ tagName === "Relationship" ||
118
+ tagName === "xdr:twoCellAnchor" ||
119
+ tagName === "xdr:oneCellAnchor" ||
120
+ tagName === "tableColumn" ||
121
+ tagName === "pivotField" ||
122
+ tagName === "cacheField" ||
123
+ tagName === "field" ||
124
+ tagName === "pageField" ||
125
+ tagName === "dataField" ||
126
+ tagName === "pivotCache",
127
+ });
128
+ const parseXmlDoc = (xml: string) => xmlParser.parse(xml) as Record<string, unknown>;
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Pivot caches (workbook scope)
132
+ // -------------------------------------------------------------------------
133
+ // workbook.xml lists each pivotCache by cacheId + r:id; the rId resolves
134
+ // through the workbook rels to a pivotCacheDefinition path. Multiple pivot
135
+ // tables on different sheets may share a cache, so we parse caches once
136
+ // here and reference them from per-sheet pivot tables below.
137
+ const pivotCaches = new Map<number, ParsedPivotCache>();
138
+ if (wbEntry) {
139
+ const wbDoc = parseXmlDoc(decodeUtf8(wbEntry));
140
+ const wbCacheMap = extractWorkbookPivotCaches(wbDoc);
141
+ for (const [cacheId, rId] of wbCacheMap) {
142
+ const cacheTarget = wbRels.get(rId);
143
+ if (!cacheTarget) continue;
144
+ const cachePath = cacheTarget.startsWith("/")
145
+ ? cacheTarget.slice(1)
146
+ : `xl/${cacheTarget}`;
147
+ const cacheEntry = findEntry(zip, cachePath);
148
+ if (!cacheEntry) continue;
149
+ try {
150
+ pivotCaches.set(
151
+ cacheId,
152
+ parsePivotCacheDefinitionXml(decodeUtf8(cacheEntry), cacheId, parseXmlDoc),
153
+ );
154
+ } catch {
155
+ // Defensive: malformed cache shouldn't break workbook load. Pivot
156
+ // tables referencing this cache will simply render as empty.
157
+ }
158
+ }
159
+ }
160
+
161
+ // Parse every sheet. Hidden/veryHidden sheets are parsed too but tagged via
162
+ // `hidden` so renderers can skip them while data consumers can read them.
163
+ const sheets: ParsedSheet[] = [];
164
+ let activeSheetIndex = 0;
165
+
166
+ let visibleIndex = 0;
167
+ for (let i = 0; i < workbookInfo.sheets.length; i++) {
168
+ const sheetInfo = workbookInfo.sheets[i];
169
+ const isHidden = sheetInfo.state === "hidden" || sheetInfo.state === "veryHidden";
170
+
171
+ const relTarget = wbRels.get(sheetInfo.rId);
172
+ if (!relTarget) continue;
173
+
174
+ const sheetPath = relTarget.startsWith("/")
175
+ ? relTarget.slice(1)
176
+ : `xl/${relTarget}`;
177
+ const sheetEntry = findEntry(zip, sheetPath);
178
+ if (!sheetEntry) continue;
179
+
180
+ const sheetRelsPath = sheetPath.replace(/([^/]+)$/, "_rels/$1.rels");
181
+ const sheetRelsEntry = findEntry(zip, sheetRelsPath);
182
+ const sheetRelsXml = sheetRelsEntry ? decodeUtf8(sheetRelsEntry) : undefined;
183
+
184
+ const parsed = parseSheet(
185
+ decodeUtf8(sheetEntry),
186
+ sheetRelsXml,
187
+ styles,
188
+ sharedStrings,
189
+ theme,
190
+ styles.indexedColors,
191
+ zip,
192
+ workbookInfo.date1904,
193
+ );
194
+ parsed.name = sheetInfo.name;
195
+ if (isHidden) parsed.hidden = true;
196
+
197
+ const printTitlesRaw = workbookInfo.printTitlesBySheet.get(i);
198
+ if (printTitlesRaw) {
199
+ const pt = parsePrintTitlesRef(printTitlesRaw);
200
+ if (pt) parsed.printTitles = pt;
201
+ }
202
+
203
+ // Extract charts, tables, drawings, and threaded comments from sheet rels
204
+ if (sheetRelsXml) {
205
+ const sheetDir = sheetPath.substring(0, sheetPath.lastIndexOf("/") + 1);
206
+ const relsDoc = parseXmlDoc(sheetRelsXml);
207
+ const relsRoot = (relsDoc["Relationships"] ?? relsDoc) as Record<string, unknown>;
208
+ const relArr = (Array.isArray(relsRoot["Relationship"])
209
+ ? relsRoot["Relationship"]
210
+ : relsRoot["Relationship"] ? [relsRoot["Relationship"]] : []) as Array<Record<string, string>>;
211
+
212
+ const relMap = new Map<string, { type: string; target: string }>();
213
+ for (const rel of relArr) {
214
+ const id = rel["@_Id"];
215
+ const target = rel["@_Target"];
216
+ const type = rel["@_Type"] ?? "";
217
+ if (id && target) relMap.set(id, { type, target });
218
+ }
219
+
220
+ // --- Drawings (contains charts and additional images) ---
221
+ const charts: ParsedChart[] = [];
222
+ const drawingRelations: DrawingRelation[] = [];
223
+ for (const [, rel] of relMap) {
224
+ if (!rel.type.includes("/drawing") || rel.type.includes("drawingML")) continue;
225
+ const drawingPath = resolveRelTarget(rel.target, sheetDir);
226
+ const drawingEntry = findEntry(zip, drawingPath);
227
+ if (!drawingEntry) continue;
228
+
229
+ const drawingXml = decodeUtf8(drawingEntry);
230
+ const relations = parseDrawingXml(drawingXml, parseXmlDoc);
231
+ drawingRelations.push(...relations);
232
+
233
+ // Parse drawing rels (for chart and image targets)
234
+ const drawingRelsPath = drawingPath.replace(/([^/]+)$/, "_rels/$1.rels");
235
+ const drawingRelsEntry = findEntry(zip, drawingRelsPath);
236
+ const drawingRels = new Map<string, string>();
237
+ if (drawingRelsEntry) {
238
+ const drawingRelsDoc = parseXmlDoc(decodeUtf8(drawingRelsEntry));
239
+ const dr = (drawingRelsDoc["Relationships"] ?? drawingRelsDoc) as Record<string, unknown>;
240
+ const drArr = (Array.isArray(dr["Relationship"])
241
+ ? dr["Relationship"]
242
+ : dr["Relationship"] ? [dr["Relationship"]] : []) as Array<Record<string, string>>;
243
+ for (const r of drArr) {
244
+ if (r["@_Id"] && r["@_Target"]) drawingRels.set(r["@_Id"], r["@_Target"]);
245
+ }
246
+ }
247
+
248
+ const drawingDir = drawingPath.substring(0, drawingPath.lastIndexOf("/") + 1);
249
+
250
+ // Load charts referenced by drawing anchors
251
+ for (const dr of relations) {
252
+ if (dr.type !== "chart" || !dr.rId) continue;
253
+ const chartTarget = drawingRels.get(dr.rId);
254
+ if (!chartTarget) continue;
255
+ const chartPath = resolveRelTarget(chartTarget, drawingDir);
256
+ const chartEntry = findEntry(zip, chartPath);
257
+ if (!chartEntry) continue;
258
+ const chartData = parseChartXml(decodeUtf8(chartEntry), parseXmlDoc);
259
+ charts.push({ ...chartData, anchor: dr.anchor });
260
+ }
261
+ }
262
+
263
+ if (charts.length > 0) parsed.charts = charts;
264
+
265
+ // Collect shape/textbox/connector drawings
266
+ const drawings = drawingRelations
267
+ .filter((dr) => dr.type === "shape" && dr.drawing)
268
+ .map((dr) => dr.drawing!);
269
+ if (drawings.length > 0) parsed.drawings = drawings;
270
+
271
+ // --- VML drawings (legacy form controls) ---
272
+ for (const [, rel] of relMap) {
273
+ if (!rel.type.includes("vmlDrawing")) continue;
274
+ const vmlPath = resolveRelTarget(rel.target, sheetDir);
275
+ const vmlEntry = findEntry(zip, vmlPath);
276
+ if (!vmlEntry) continue;
277
+ const vmlDrawings = parseVmlDrawing(decodeUtf8(vmlEntry), parseXmlDoc);
278
+ if (vmlDrawings.length > 0) {
279
+ if (!parsed.drawings) parsed.drawings = [];
280
+ parsed.drawings.push(...vmlDrawings);
281
+ }
282
+ }
283
+
284
+ // --- Tables ---
285
+ const tableRIds = extractTableRIds(relsDoc);
286
+ const tables = [];
287
+ for (const [, target] of tableRIds) {
288
+ const tablePath = resolveRelTarget(target, sheetDir);
289
+ const tableEntry = findEntry(zip, tablePath);
290
+ if (!tableEntry) continue;
291
+ tables.push(parseTableXml(decodeUtf8(tableEntry), parseXmlDoc));
292
+ }
293
+ if (tables.length > 0) parsed.tables = tables;
294
+
295
+ // --- Pivot tables ---
296
+ const pivotRIds = extractPivotTableRIds(relsDoc);
297
+ const pivotTables: ParsedPivotTable[] = [];
298
+ for (const [, target] of pivotRIds) {
299
+ const pivotPath = resolveRelTarget(target, sheetDir);
300
+ const pivotEntry = findEntry(zip, pivotPath);
301
+ if (!pivotEntry) continue;
302
+ try {
303
+ pivotTables.push(
304
+ parsePivotTableXml(decodeUtf8(pivotEntry), parseXmlDoc),
305
+ );
306
+ } catch {
307
+ // Defensive: skip malformed pivots rather than failing the whole load.
308
+ }
309
+ }
310
+ if (pivotTables.length > 0) parsed.pivotTables = pivotTables;
311
+ }
312
+
313
+ sheets.push(parsed);
314
+
315
+ if (i === workbookInfo.activeSheetIndex) {
316
+ activeSheetIndex = visibleIndex;
317
+ }
318
+ visibleIndex++;
319
+ }
320
+
321
+ if (sheets.length === 0) {
322
+ return { sheets: [emptySheet("Sheet1")], activeSheetIndex: 0 };
323
+ }
324
+
325
+ if (activeSheetIndex >= sheets.length) activeSheetIndex = 0;
326
+
327
+ const result: ParsedSpreadsheet = { sheets, activeSheetIndex };
328
+ if (workbookInfo.namedRanges.size > 0) {
329
+ result.namedRanges = workbookInfo.namedRanges;
330
+ }
331
+ if (workbookInfo.fullCalcOnLoad) {
332
+ result.fullCalcOnLoad = true;
333
+ }
334
+ if (pivotCaches.size > 0) {
335
+ result.pivotCaches = pivotCaches;
336
+ }
337
+ return result;
338
+ }
339
+
340
+ // =============================================================================
341
+ // Helpers
342
+ // =============================================================================
343
+
344
+ function findEntry(zip: Record<string, Uint8Array>, path: string): Uint8Array | undefined {
345
+ if (zip[path]) return zip[path];
346
+ const lower = path.toLowerCase();
347
+ for (const key of Object.keys(zip)) {
348
+ if (key.toLowerCase() === lower) return zip[key];
349
+ }
350
+ return undefined;
351
+ }
352
+
353
+ /**
354
+ * Resolve a relationship target path relative to a base directory.
355
+ * Handles absolute paths (leading /), relative paths with ../, and plain relative paths.
356
+ */
357
+ function resolveRelTarget(target: string, baseDir: string): string {
358
+ if (target.startsWith("/")) return target.slice(1);
359
+ const baseParts = baseDir.split("/").filter(Boolean);
360
+ const targetParts = target.split("/");
361
+ const resolved = [...baseParts];
362
+ for (const part of targetParts) {
363
+ if (part === "..") {
364
+ resolved.pop();
365
+ } else if (part !== ".") {
366
+ resolved.push(part);
367
+ }
368
+ }
369
+ return resolved.join("/");
370
+ }
371
+
372
+ /**
373
+ * Parse a `_xlnm.Print_Titles` raw reference string into repeat row/col ranges.
374
+ * Example: `"Sheet1!$A:$B,Sheet1!$1:$2"` → `{ repeatRows: [1, 2], repeatCols: [1, 2] }`.
375
+ */
376
+ function parsePrintTitlesRef(raw: string): PrintTitles | undefined {
377
+ const result: PrintTitles = {};
378
+ for (const segment of raw.split(",")) {
379
+ const afterBang = segment.includes("!") ? segment.split("!")[1] : segment;
380
+ if (!afterBang) continue;
381
+ const rowMatch = afterBang.match(/^\$(\d+):\$(\d+)$/);
382
+ if (rowMatch) {
383
+ result.repeatRows = [parseInt(rowMatch[1], 10), parseInt(rowMatch[2], 10)];
384
+ continue;
385
+ }
386
+ const colMatch = afterBang.match(/^\$([A-Z]+):\$([A-Z]+)$/i);
387
+ if (colMatch) {
388
+ result.repeatCols = [colLettersToNum(colMatch[1]), colLettersToNum(colMatch[2])];
389
+ }
390
+ }
391
+ return result.repeatRows || result.repeatCols ? result : undefined;
392
+ }
393
+
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Maps ExcelJS FillPattern types to CSS background-image strings.
3
+ * Uses repeating-linear-gradient for stripe/grid patterns
4
+ * and inline SVG data URIs for dot patterns.
5
+ */
6
+
7
+ type PatternGenerator = (fg: string, bg: string) => string;
8
+
9
+ const PATTERNS: Record<string, PatternGenerator> = {
10
+ darkVertical: (fg, bg) =>
11
+ `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 4px)`,
12
+ darkHorizontal: (fg, bg) =>
13
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 4px)`,
14
+ darkDown: (fg, bg) =>
15
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 5px)`,
16
+ darkUp: (fg, bg) =>
17
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 5px)`,
18
+ darkGrid: (fg, bg) =>
19
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent 4px), ` +
20
+ `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 4px)`,
21
+ darkTrellis: (fg, bg) =>
22
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent 5px), ` +
23
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 5px)`,
24
+
25
+ lightVertical: (fg, bg) =>
26
+ `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
27
+ lightHorizontal: (fg, bg) =>
28
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
29
+ lightDown: (fg, bg) =>
30
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
31
+ lightUp: (fg, bg) =>
32
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
33
+ lightGrid: (fg, bg) =>
34
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent 8px), ` +
35
+ `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
36
+ lightTrellis: (fg, bg) =>
37
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent 8px), ` +
38
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
39
+
40
+ mediumGray: (fg, bg) =>
41
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 3px)`,
42
+ darkGray: (fg, bg) =>
43
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 2px, ${bg} 2px, ${bg} 3px)`,
44
+ lightGray: (fg, bg) =>
45
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 5px)`,
46
+
47
+ // gray125 = 12.5% gray (every 8th pixel filled)
48
+ gray125: (fg, bg) =>
49
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 8px)`,
50
+
51
+ // gray0625 = 6.25% gray (every 16th pixel filled)
52
+ gray0625: (fg, bg) =>
53
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, ${bg} 1px, ${bg} 16px)`,
54
+ };
55
+
56
+ export function getPatternFillCSS(
57
+ pattern: string,
58
+ fgColor: string,
59
+ bgColor: string,
60
+ ): string | undefined {
61
+ const generator = PATTERNS[pattern];
62
+ if (!generator) return undefined;
63
+ return generator(fgColor, bgColor);
64
+ }
@@ -0,0 +1,160 @@
1
+ // =============================================================================
2
+ // Excel Table Styles
3
+ // =============================================================================
4
+ //
5
+ // Built-in table style definitions for structured tables.
6
+ // Excel has ~80 built-in styles (TableStyleLight1-21, TableStyleMedium1-28,
7
+ // TableStyleDark1-11). Each style defines colors for header, total,
8
+ // first/last column, and banded row/column stripes.
9
+ // =============================================================================
10
+
11
+ // =============================================================================
12
+ // Types
13
+ // =============================================================================
14
+
15
+ export interface TableStyleDef {
16
+ /** Style name (e.g., "TableStyleMedium2"). */
17
+ name: string;
18
+ /** Header row background. */
19
+ headerBg: string;
20
+ /** Header row text color. */
21
+ headerFg: string;
22
+ /** Total row background. */
23
+ totalBg: string;
24
+ /** Total row text color. */
25
+ totalFg: string;
26
+ /** Odd row stripe background. */
27
+ stripe1Bg: string;
28
+ /** Even row stripe background (empty = transparent). */
29
+ stripe2Bg: string;
30
+ /** First column highlight. */
31
+ firstColBg?: string;
32
+ /** Last column highlight. */
33
+ lastColBg?: string;
34
+ }
35
+
36
+ // =============================================================================
37
+ // Built-in styles
38
+ // =============================================================================
39
+
40
+ const LIGHT_STYLES: TableStyleDef[] = buildStyleSet("TableStyleLight", [
41
+ { header: "#000000", headerFg: "#FFFFFF", s1: "#D9E2F3", s2: "" },
42
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#D6E4F0", s2: "" },
43
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#FCE4D6", s2: "" },
44
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#EDEDED", s2: "" },
45
+ { header: "#FFC000", headerFg: "#FFFFFF", s1: "#FFF2CC", s2: "" },
46
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#DDEBF7", s2: "" },
47
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#E2EFDA", s2: "" },
48
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#D6E4F0", s2: "#EDF2F9" },
49
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#FCE4D6", s2: "#FDF0E8" },
50
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#EDEDED", s2: "#F6F6F6" },
51
+ { header: "#FFC000", headerFg: "#000000", s1: "#FFF2CC", s2: "#FFF8E6" },
52
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#DDEBF7", s2: "#EDF4FB" },
53
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#E2EFDA", s2: "#F0F7EC" },
54
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#B4C6E7", s2: "#D6E4F0" },
55
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#F8CBAD", s2: "#FCE4D6" },
56
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#DBDBDB", s2: "#EDEDED" },
57
+ { header: "#FFC000", headerFg: "#000000", s1: "#FFE699", s2: "#FFF2CC" },
58
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#BDD7EE", s2: "#DDEBF7" },
59
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#C6E0B4", s2: "#E2EFDA" },
60
+ { header: "#000000", headerFg: "#FFFFFF", s1: "#F2F2F2", s2: "" },
61
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#D6E4F0", s2: "" },
62
+ ]);
63
+
64
+ const MEDIUM_STYLES: TableStyleDef[] = buildStyleSet("TableStyleMedium", [
65
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#D6E4F0", s2: "" },
66
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#FCE4D6", s2: "" },
67
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#EDEDED", s2: "" },
68
+ { header: "#FFC000", headerFg: "#000000", s1: "#FFF2CC", s2: "" },
69
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#DDEBF7", s2: "" },
70
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#E2EFDA", s2: "" },
71
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#D6E4F0", s2: "#EDF2F9" },
72
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#FCE4D6", s2: "#FDF0E8" },
73
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#EDEDED", s2: "#F6F6F6" },
74
+ { header: "#FFC000", headerFg: "#000000", s1: "#FFF2CC", s2: "#FFF8E6" },
75
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#DDEBF7", s2: "#EDF4FB" },
76
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#E2EFDA", s2: "#F0F7EC" },
77
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#B4C6E7", s2: "#D6E4F0" },
78
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#F8CBAD", s2: "#FCE4D6" },
79
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#DBDBDB", s2: "#EDEDED" },
80
+ { header: "#FFC000", headerFg: "#000000", s1: "#FFE699", s2: "#FFF2CC" },
81
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#BDD7EE", s2: "#DDEBF7" },
82
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#C6E0B4", s2: "#E2EFDA" },
83
+ { header: "#000000", headerFg: "#FFFFFF", s1: "#D9D9D9", s2: "#F2F2F2" },
84
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#8FAADC", s2: "#B4C6E7" },
85
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#F4B183", s2: "#F8CBAD" },
86
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#C9C9C9", s2: "#DBDBDB" },
87
+ { header: "#FFC000", headerFg: "#000000", s1: "#FFD966", s2: "#FFE699" },
88
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#9DC3E6", s2: "#BDD7EE" },
89
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#A9D18E", s2: "#C6E0B4" },
90
+ { header: "#264478", headerFg: "#FFFFFF", s1: "#D6DCE4", s2: "#E9ECF1" },
91
+ { header: "#9B57A0", headerFg: "#FFFFFF", s1: "#E8D5EA", s2: "#F3EAF4" },
92
+ { header: "#636363", headerFg: "#FFFFFF", s1: "#E0E0E0", s2: "#F0F0F0" },
93
+ ]);
94
+
95
+ const DARK_STYLES: TableStyleDef[] = buildStyleSet("TableStyleDark", [
96
+ { header: "#000000", headerFg: "#FFFFFF", s1: "#737373", s2: "#595959" },
97
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#44546A", s2: "#2F3B52" },
98
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#C55A11", s2: "#843C0C" },
99
+ { header: "#A5A5A5", headerFg: "#FFFFFF", s1: "#7F7F7F", s2: "#595959" },
100
+ { header: "#FFC000", headerFg: "#000000", s1: "#BF8F00", s2: "#806000" },
101
+ { header: "#5B9BD5", headerFg: "#FFFFFF", s1: "#2E75B6", s2: "#1F4E79" },
102
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#548235", s2: "#375623" },
103
+ { header: "#000000", headerFg: "#FFFFFF", s1: "#404040", s2: "#262626" },
104
+ { header: "#4472C4", headerFg: "#FFFFFF", s1: "#305496", s2: "#203764" },
105
+ { header: "#ED7D31", headerFg: "#FFFFFF", s1: "#BF8F00", s2: "#806000" },
106
+ { header: "#70AD47", headerFg: "#FFFFFF", s1: "#375623", s2: "#1D3311" },
107
+ ]);
108
+
109
+ // =============================================================================
110
+ // Style lookup
111
+ // =============================================================================
112
+
113
+ const ALL_STYLES = new Map<string, TableStyleDef>();
114
+
115
+ function initStyles(): void {
116
+ if (ALL_STYLES.size > 0) return;
117
+ for (const s of [...LIGHT_STYLES, ...MEDIUM_STYLES, ...DARK_STYLES]) {
118
+ ALL_STYLES.set(s.name, s);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get a table style definition by name.
124
+ * Returns a default light style if not found.
125
+ */
126
+ export function getTableStyle(name: string): TableStyleDef {
127
+ initStyles();
128
+ return ALL_STYLES.get(name) ?? LIGHT_STYLES[0];
129
+ }
130
+
131
+ /**
132
+ * Get all available table style names.
133
+ */
134
+ export function getTableStyleNames(): string[] {
135
+ initStyles();
136
+ return Array.from(ALL_STYLES.keys());
137
+ }
138
+
139
+ // =============================================================================
140
+ // Builder
141
+ // =============================================================================
142
+
143
+ interface StyleInput {
144
+ header: string;
145
+ headerFg: string;
146
+ s1: string;
147
+ s2: string;
148
+ }
149
+
150
+ function buildStyleSet(prefix: string, inputs: StyleInput[]): TableStyleDef[] {
151
+ return inputs.map((input, i) => ({
152
+ name: `${prefix}${i + 1}`,
153
+ headerBg: input.header,
154
+ headerFg: input.headerFg,
155
+ totalBg: input.header,
156
+ totalFg: input.headerFg,
157
+ stripe1Bg: input.s1,
158
+ stripe2Bg: input.s2,
159
+ }));
160
+ }