@niicojs/excel 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/styles.ts CHANGED
@@ -1,819 +1,819 @@
1
- import type { CellStyle, BorderType } from './types';
2
- import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
3
-
4
- /**
5
- * Excel built-in number format IDs (0-163 are reserved).
6
- * These formats don't need to be defined in the numFmts element.
7
- */
8
- const BUILTIN_NUM_FMTS: Map<string, number> = new Map([
9
- ['General', 0],
10
- ['0', 1],
11
- ['0.00', 2],
12
- ['#,##0', 3],
13
- ['#,##0.00', 4],
14
- ['0%', 9],
15
- ['0.00%', 10],
16
- ['0.00E+00', 11],
17
- ['# ?/?', 12],
18
- ['# ??/??', 13],
19
- ['mm-dd-yy', 14],
20
- ['d-mmm-yy', 15],
21
- ['d-mmm', 16],
22
- ['mmm-yy', 17],
23
- ['h:mm AM/PM', 18],
24
- ['h:mm:ss AM/PM', 19],
25
- ['h:mm', 20],
26
- ['h:mm:ss', 21],
27
- ['m/d/yy h:mm', 22],
28
- ['#,##0 ;(#,##0)', 37],
29
- ['#,##0 ;[Red](#,##0)', 38],
30
- ['#,##0.00;(#,##0.00)', 39],
31
- ['#,##0.00;[Red](#,##0.00)', 40],
32
- ['mm:ss', 45],
33
- ['[h]:mm:ss', 46],
34
- ['mmss.0', 47],
35
- ['##0.0E+0', 48],
36
- ['@', 49],
37
- ]);
38
-
39
- /**
40
- * Reverse lookup: built-in format ID -> format code
41
- */
42
- const BUILTIN_NUM_FMT_CODES: Map<number, string> = new Map(
43
- Array.from(BUILTIN_NUM_FMTS.entries()).map(([code, id]) => [id, code]),
44
- );
45
-
46
- /**
47
- * Normalize a color to ARGB format (8 hex chars).
48
- * Accepts: "#RGB", "#RRGGBB", "RGB", "RRGGBB", "AARRGGBB", "#AARRGGBB"
49
- */
50
- const normalizeColor = (color: string): string => {
51
- let c = color.replace(/^#/, '').toUpperCase();
52
-
53
- // Handle shorthand 3-char format (e.g., "FFF" -> "FFFFFF")
54
- if (c.length === 3) {
55
- c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
56
- }
57
-
58
- // Add alpha channel if not present (6 chars -> 8 chars)
59
- if (c.length === 6) {
60
- c = 'FF' + c;
61
- }
62
-
63
- return c;
64
- };
65
-
66
- interface StyleColor {
67
- rgb?: string;
68
- theme?: string;
69
- tint?: string;
70
- indexed?: string;
71
- }
72
-
73
- /**
74
- * Manages the styles (xl/styles.xml)
75
- */
76
- export class Styles {
77
- private _numFmts: Map<number, string> = new Map();
78
- private _fonts: StyleFont[] = [];
79
- private _fills: StyleFill[] = [];
80
- private _borders: StyleBorder[] = [];
81
- private _cellXfs: CellXf[] = []; // Cell formats (combined style index)
82
- private _xmlNodes: XmlNode[] | null = null;
83
- private _dirty = false;
84
-
85
- // Cache for style deduplication
86
- private _styleCache: Map<string, number> = new Map();
87
- private _styleObjectCache: Map<number, CellStyle> = new Map();
88
-
89
- /**
90
- * Generate a deterministic cache key for a style object.
91
- * More efficient than JSON.stringify as it avoids the overhead of
92
- * full JSON serialization and produces a consistent key regardless
93
- * of property order.
94
- */
95
- private _getStyleKey(style: CellStyle): string {
96
- // Use a delimiter that won't appear in values
97
- const SEP = '\x00';
98
-
99
- // Build key from all style properties in a fixed order
100
- const parts: string[] = [
101
- style.bold ? '1' : '0',
102
- style.italic ? '1' : '0',
103
- style.underline === true ? '1' : style.underline === 'single' ? 's' : style.underline === 'double' ? 'd' : '0',
104
- style.strike ? '1' : '0',
105
- style.fontSize?.toString() ?? '',
106
- style.fontName ?? '',
107
- style.fontColor ?? '',
108
- style.fontColorTheme?.toString() ?? '',
109
- style.fontColorTint?.toString() ?? '',
110
- style.fontColorIndexed?.toString() ?? '',
111
- style.fill ?? '',
112
- style.fillTheme?.toString() ?? '',
113
- style.fillTint?.toString() ?? '',
114
- style.fillIndexed?.toString() ?? '',
115
- style.fillBgColor ?? '',
116
- style.fillBgTheme?.toString() ?? '',
117
- style.fillBgTint?.toString() ?? '',
118
- style.fillBgIndexed?.toString() ?? '',
119
- style.numberFormat ?? '',
120
- ];
121
-
122
- // Border properties
123
- if (style.border) {
124
- parts.push(style.border.top ?? '', style.border.bottom ?? '', style.border.left ?? '', style.border.right ?? '');
125
- } else {
126
- parts.push('', '', '', '');
127
- }
128
-
129
- // Alignment properties
130
- if (style.alignment) {
131
- parts.push(
132
- style.alignment.horizontal ?? '',
133
- style.alignment.vertical ?? '',
134
- style.alignment.wrapText ? '1' : '0',
135
- style.alignment.textRotation?.toString() ?? '',
136
- );
137
- } else {
138
- parts.push('', '', '0', '');
139
- }
140
-
141
- return parts.join(SEP);
142
- }
143
-
144
- /**
145
- * Parse styles from XML content
146
- */
147
- static parse(xml: string): Styles {
148
- const styles = new Styles();
149
- styles._xmlNodes = parseXml(xml);
150
-
151
- const styleSheet = findElement(styles._xmlNodes, 'styleSheet');
152
- if (!styleSheet) return styles;
153
-
154
- const children = getChildren(styleSheet, 'styleSheet');
155
-
156
- // Parse number formats
157
- const numFmts = findElement(children, 'numFmts');
158
- if (numFmts) {
159
- for (const child of getChildren(numFmts, 'numFmts')) {
160
- if ('numFmt' in child) {
161
- const id = parseInt(getAttr(child, 'numFmtId') || '0', 10);
162
- const code = getAttr(child, 'formatCode') || '';
163
- styles._numFmts.set(id, code);
164
- }
165
- }
166
- }
167
-
168
- // Parse fonts
169
- const fonts = findElement(children, 'fonts');
170
- if (fonts) {
171
- for (const child of getChildren(fonts, 'fonts')) {
172
- if ('font' in child) {
173
- styles._fonts.push(styles._parseFont(child));
174
- }
175
- }
176
- }
177
-
178
- // Parse fills
179
- const fills = findElement(children, 'fills');
180
- if (fills) {
181
- for (const child of getChildren(fills, 'fills')) {
182
- if ('fill' in child) {
183
- styles._fills.push(styles._parseFill(child));
184
- }
185
- }
186
- }
187
-
188
- // Parse borders
189
- const borders = findElement(children, 'borders');
190
- if (borders) {
191
- for (const child of getChildren(borders, 'borders')) {
192
- if ('border' in child) {
193
- styles._borders.push(styles._parseBorder(child));
194
- }
195
- }
196
- }
197
-
198
- // Parse cellXfs (cell formats)
199
- const cellXfs = findElement(children, 'cellXfs');
200
- if (cellXfs) {
201
- for (const child of getChildren(cellXfs, 'cellXfs')) {
202
- if ('xf' in child) {
203
- styles._cellXfs.push(styles._parseCellXf(child));
204
- }
205
- }
206
- }
207
-
208
- return styles;
209
- }
210
-
211
- /**
212
- * Create an empty styles object with defaults
213
- */
214
- static createDefault(): Styles {
215
- const styles = new Styles();
216
-
217
- // Default font (Calibri 11)
218
- styles._fonts.push({
219
- bold: false,
220
- italic: false,
221
- underline: false,
222
- strike: false,
223
- size: 11,
224
- name: 'Calibri',
225
- color: undefined,
226
- });
227
-
228
- // Default fills (none and gray125 pattern are required)
229
- styles._fills.push({ type: 'none' });
230
- styles._fills.push({ type: 'gray125' });
231
-
232
- // Default border (none)
233
- styles._borders.push({});
234
-
235
- // Default cell format
236
- styles._cellXfs.push({
237
- fontId: 0,
238
- fillId: 0,
239
- borderId: 0,
240
- numFmtId: 0,
241
- });
242
-
243
- return styles;
244
- }
245
-
246
- private _parseFont(node: XmlNode): StyleFont {
247
- const font: StyleFont = {
248
- bold: false,
249
- italic: false,
250
- underline: false,
251
- strike: false,
252
- };
253
-
254
- const children = getChildren(node, 'font');
255
- for (const child of children) {
256
- if ('b' in child) font.bold = true;
257
- if ('i' in child) font.italic = true;
258
- if ('u' in child) font.underline = true;
259
- if ('strike' in child) font.strike = true;
260
- if ('sz' in child) font.size = parseFloat(getAttr(child, 'val') || '11');
261
- if ('name' in child) font.name = getAttr(child, 'val');
262
- if ('color' in child) {
263
- const color: StyleColor = {};
264
- const rgb = getAttr(child, 'rgb');
265
- const theme = getAttr(child, 'theme');
266
- const tint = getAttr(child, 'tint');
267
- const indexed = getAttr(child, 'indexed');
268
- if (rgb) color.rgb = rgb;
269
- if (theme) color.theme = theme;
270
- if (tint) color.tint = tint;
271
- if (indexed) color.indexed = indexed;
272
- if (color.rgb || color.theme || color.tint || color.indexed) {
273
- font.color = color;
274
- }
275
- }
276
- }
277
-
278
- return font;
279
- }
280
-
281
- private _parseFill(node: XmlNode): StyleFill {
282
- const fill: StyleFill = { type: 'none' };
283
- const children = getChildren(node, 'fill');
284
-
285
- for (const child of children) {
286
- if ('patternFill' in child) {
287
- const pattern = getAttr(child, 'patternType');
288
- fill.type = pattern || 'none';
289
-
290
- const pfChildren = getChildren(child, 'patternFill');
291
- for (const pfChild of pfChildren) {
292
- if ('fgColor' in pfChild) {
293
- const color: StyleColor = {};
294
- const rgb = getAttr(pfChild, 'rgb');
295
- const theme = getAttr(pfChild, 'theme');
296
- const tint = getAttr(pfChild, 'tint');
297
- const indexed = getAttr(pfChild, 'indexed');
298
- if (rgb) color.rgb = rgb;
299
- if (theme) color.theme = theme;
300
- if (tint) color.tint = tint;
301
- if (indexed) color.indexed = indexed;
302
- if (color.rgb || color.theme || color.tint || color.indexed) {
303
- fill.fgColor = color;
304
- }
305
- }
306
- if ('bgColor' in pfChild) {
307
- const color: StyleColor = {};
308
- const rgb = getAttr(pfChild, 'rgb');
309
- const theme = getAttr(pfChild, 'theme');
310
- const tint = getAttr(pfChild, 'tint');
311
- const indexed = getAttr(pfChild, 'indexed');
312
- if (rgb) color.rgb = rgb;
313
- if (theme) color.theme = theme;
314
- if (tint) color.tint = tint;
315
- if (indexed) color.indexed = indexed;
316
- if (color.rgb || color.theme || color.tint || color.indexed) {
317
- fill.bgColor = color;
318
- }
319
- }
320
- }
321
- }
322
- }
323
-
324
- return fill;
325
- }
326
-
327
- private _parseBorder(node: XmlNode): StyleBorder {
328
- const border: StyleBorder = {};
329
- const children = getChildren(node, 'border');
330
-
331
- for (const child of children) {
332
- const style = getAttr(child, 'style') as BorderType | undefined;
333
- if ('left' in child && style) border.left = style;
334
- if ('right' in child && style) border.right = style;
335
- if ('top' in child && style) border.top = style;
336
- if ('bottom' in child && style) border.bottom = style;
337
- }
338
-
339
- return border;
340
- }
341
-
342
- private _parseCellXf(node: XmlNode): CellXf {
343
- return {
344
- fontId: parseInt(getAttr(node, 'fontId') || '0', 10),
345
- fillId: parseInt(getAttr(node, 'fillId') || '0', 10),
346
- borderId: parseInt(getAttr(node, 'borderId') || '0', 10),
347
- numFmtId: parseInt(getAttr(node, 'numFmtId') || '0', 10),
348
- alignment: this._parseAlignment(node),
349
- };
350
- }
351
-
352
- private _parseAlignment(node: XmlNode): AlignmentStyle | undefined {
353
- const children = getChildren(node, 'xf');
354
- const alignNode = findElement(children, 'alignment');
355
- if (!alignNode) return undefined;
356
-
357
- return {
358
- horizontal: getAttr(alignNode, 'horizontal') as AlignmentStyle['horizontal'],
359
- vertical: getAttr(alignNode, 'vertical') as AlignmentStyle['vertical'],
360
- wrapText: getAttr(alignNode, 'wrapText') === '1',
361
- textRotation: parseInt(getAttr(alignNode, 'textRotation') || '0', 10),
362
- };
363
- }
364
-
365
- /**
366
- * Get a style by index
367
- */
368
- getStyle(index: number): CellStyle {
369
- const cached = this._styleObjectCache.get(index);
370
- if (cached) return { ...cached };
371
-
372
- const xf = this._cellXfs[index];
373
- if (!xf) return {};
374
-
375
- const font = this._fonts[xf.fontId];
376
- const fill = this._fills[xf.fillId];
377
- const border = this._borders[xf.borderId];
378
- // Check custom formats first, then fall back to built-in format codes
379
- const numFmt = this._numFmts.get(xf.numFmtId) ?? BUILTIN_NUM_FMT_CODES.get(xf.numFmtId);
380
-
381
- const style: CellStyle = {};
382
-
383
- if (font) {
384
- if (font.bold) style.bold = true;
385
- if (font.italic) style.italic = true;
386
- if (font.underline) style.underline = true;
387
- if (font.strike) style.strike = true;
388
- if (font.size) style.fontSize = font.size;
389
- if (font.name) style.fontName = font.name;
390
- if (font.color?.rgb) style.fontColor = font.color.rgb;
391
- if (font.color?.theme) style.fontColorTheme = Number(font.color.theme);
392
- if (font.color?.tint) style.fontColorTint = Number(font.color.tint);
393
- if (font.color?.indexed) style.fontColorIndexed = Number(font.color.indexed);
394
- }
395
-
396
- if (fill && fill.fgColor) {
397
- if (fill.fgColor.rgb) style.fill = fill.fgColor.rgb;
398
- if (fill.fgColor.theme) style.fillTheme = Number(fill.fgColor.theme);
399
- if (fill.fgColor.tint) style.fillTint = Number(fill.fgColor.tint);
400
- if (fill.fgColor.indexed) style.fillIndexed = Number(fill.fgColor.indexed);
401
- }
402
-
403
- if (fill && fill.bgColor) {
404
- if (fill.bgColor.rgb) style.fillBgColor = fill.bgColor.rgb;
405
- if (fill.bgColor.theme) style.fillBgTheme = Number(fill.bgColor.theme);
406
- if (fill.bgColor.tint) style.fillBgTint = Number(fill.bgColor.tint);
407
- if (fill.bgColor.indexed) style.fillBgIndexed = Number(fill.bgColor.indexed);
408
- }
409
-
410
- if (border) {
411
- if (border.top || border.bottom || border.left || border.right) {
412
- style.border = {
413
- top: border.top,
414
- bottom: border.bottom,
415
- left: border.left,
416
- right: border.right,
417
- };
418
- }
419
- }
420
-
421
- if (numFmt) {
422
- style.numberFormat = numFmt;
423
- }
424
-
425
- if (xf.alignment) {
426
- style.alignment = {
427
- horizontal: xf.alignment.horizontal,
428
- vertical: xf.alignment.vertical,
429
- wrapText: xf.alignment.wrapText,
430
- textRotation: xf.alignment.textRotation,
431
- };
432
- }
433
-
434
- this._styleObjectCache.set(index, { ...style });
435
- return style;
436
- }
437
-
438
- /**
439
- * Create a style and return its index
440
- * Uses caching to deduplicate identical styles
441
- */
442
- createStyle(style: CellStyle): number {
443
- const key = this._getStyleKey(style);
444
- const cached = this._styleCache.get(key);
445
- if (cached !== undefined) {
446
- return cached;
447
- }
448
-
449
- this._dirty = true;
450
-
451
- // Create or find font
452
- const fontId = this._findOrCreateFont(style);
453
-
454
- // Create or find fill
455
- const fillId = this._findOrCreateFill(style);
456
-
457
- // Create or find border
458
- const borderId = this._findOrCreateBorder(style);
459
-
460
- // Create or find number format
461
- const numFmtId = style.numberFormat ? this._findOrCreateNumFmt(style.numberFormat) : 0;
462
-
463
- // Create cell format
464
- const xf: CellXf = {
465
- fontId,
466
- fillId,
467
- borderId,
468
- numFmtId,
469
- };
470
-
471
- if (style.alignment) {
472
- xf.alignment = {
473
- horizontal: style.alignment.horizontal,
474
- vertical: style.alignment.vertical,
475
- wrapText: style.alignment.wrapText,
476
- textRotation: style.alignment.textRotation,
477
- };
478
- }
479
-
480
- const index = this._cellXfs.length;
481
- this._cellXfs.push(xf);
482
- this._styleCache.set(key, index);
483
- this._styleObjectCache.set(index, { ...style });
484
-
485
- return index;
486
- }
487
-
488
- /**
489
- * Clone an existing style by index, optionally overriding fields.
490
- */
491
- cloneStyle(index: number, overrides: Partial<CellStyle> = {}): number {
492
- const baseStyle = this.getStyle(index);
493
- return this.createStyle({ ...baseStyle, ...overrides });
494
- }
495
-
496
- private _findOrCreateFont(style: CellStyle): number {
497
- const color = this._toStyleColor(
498
- style.fontColor,
499
- style.fontColorTheme,
500
- style.fontColorTint,
501
- style.fontColorIndexed,
502
- );
503
- const font: StyleFont = {
504
- bold: style.bold || false,
505
- italic: style.italic || false,
506
- underline: style.underline === true || style.underline === 'single' || style.underline === 'double',
507
- strike: style.strike || false,
508
- size: style.fontSize,
509
- name: style.fontName,
510
- color,
511
- };
512
-
513
- // Try to find existing font
514
- for (let i = 0; i < this._fonts.length; i++) {
515
- const f = this._fonts[i];
516
- if (
517
- f.bold === font.bold &&
518
- f.italic === font.italic &&
519
- f.underline === font.underline &&
520
- f.strike === font.strike &&
521
- f.size === font.size &&
522
- f.name === font.name &&
523
- this._colorsEqual(f.color, font.color)
524
- ) {
525
- return i;
526
- }
527
- }
528
-
529
- // Create new font
530
- this._fonts.push(font);
531
- return this._fonts.length - 1;
532
- }
533
-
534
- private _findOrCreateFill(style: CellStyle): number {
535
- const fgColor = this._toStyleColor(style.fill, style.fillTheme, style.fillTint, style.fillIndexed);
536
- const bgColor = this._toStyleColor(style.fillBgColor, style.fillBgTheme, style.fillBgTint, style.fillBgIndexed);
537
-
538
- if (!fgColor && !bgColor) return 0;
539
-
540
- // Try to find existing fill
541
- for (let i = 0; i < this._fills.length; i++) {
542
- const f = this._fills[i];
543
- if (this._colorsEqual(f.fgColor, fgColor) && this._colorsEqual(f.bgColor, bgColor)) {
544
- return i;
545
- }
546
- }
547
-
548
- // Create new fill
549
- this._fills.push({
550
- type: 'solid',
551
- fgColor: fgColor || undefined,
552
- bgColor: bgColor || undefined,
553
- });
554
- return this._fills.length - 1;
555
- }
556
-
557
- private _findOrCreateBorder(style: CellStyle): number {
558
- if (!style.border) return 0;
559
-
560
- const border: StyleBorder = {
561
- top: style.border.top,
562
- bottom: style.border.bottom,
563
- left: style.border.left,
564
- right: style.border.right,
565
- };
566
-
567
- // Try to find existing border
568
- for (let i = 0; i < this._borders.length; i++) {
569
- const b = this._borders[i];
570
- if (b.top === border.top && b.bottom === border.bottom && b.left === border.left && b.right === border.right) {
571
- return i;
572
- }
573
- }
574
-
575
- // Create new border
576
- this._borders.push(border);
577
- return this._borders.length - 1;
578
- }
579
-
580
- private _findOrCreateNumFmt(format: string): number {
581
- // Check built-in formats first (IDs 0-163)
582
- const builtinId = BUILTIN_NUM_FMTS.get(format);
583
- if (builtinId !== undefined) {
584
- return builtinId;
585
- }
586
-
587
- // Check if already exists in custom formats
588
- for (const [id, code] of this._numFmts) {
589
- if (code === format) return id;
590
- }
591
-
592
- // Create new custom format (IDs 164+)
593
- const existingIds = Array.from(this._numFmts.keys());
594
- const id = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 164;
595
- this._numFmts.set(id, format);
596
- return id;
597
- }
598
-
599
- /**
600
- * Get or create a number format ID for the given format string.
601
- * Returns built-in IDs (0-163) for standard formats, or creates custom IDs (164+).
602
- * @param format - The number format string (e.g., '0.00', '#,##0', '$#,##0.00')
603
- */
604
- getOrCreateNumFmtId(format: string): number {
605
- this._dirty = true;
606
- return this._findOrCreateNumFmt(format);
607
- }
608
-
609
- /**
610
- * Check if styles have been modified
611
- */
612
- get dirty(): boolean {
613
- return this._dirty;
614
- }
615
-
616
- /**
617
- * Generate XML for styles
618
- */
619
- toXml(): string {
620
- const children: XmlNode[] = [];
621
-
622
- // Number formats
623
- if (this._numFmts.size > 0) {
624
- const numFmtNodes: XmlNode[] = [];
625
- for (const [id, code] of this._numFmts) {
626
- numFmtNodes.push(createElement('numFmt', { numFmtId: String(id), formatCode: code }, []));
627
- }
628
- children.push(createElement('numFmts', { count: String(numFmtNodes.length) }, numFmtNodes));
629
- }
630
-
631
- // Fonts
632
- const fontNodes: XmlNode[] = this._fonts.map((font) => this._buildFontNode(font));
633
- children.push(createElement('fonts', { count: String(fontNodes.length) }, fontNodes));
634
-
635
- // Fills
636
- const fillNodes: XmlNode[] = this._fills.map((fill) => this._buildFillNode(fill));
637
- children.push(createElement('fills', { count: String(fillNodes.length) }, fillNodes));
638
-
639
- // Borders
640
- const borderNodes: XmlNode[] = this._borders.map((border) => this._buildBorderNode(border));
641
- children.push(createElement('borders', { count: String(borderNodes.length) }, borderNodes));
642
-
643
- // Cell style xfs (required but we just add a default)
644
- children.push(
645
- createElement('cellStyleXfs', { count: '1' }, [
646
- createElement('xf', { numFmtId: '0', fontId: '0', fillId: '0', borderId: '0' }, []),
647
- ]),
648
- );
649
-
650
- // Cell xfs
651
- const xfNodes: XmlNode[] = this._cellXfs.map((xf) => this._buildXfNode(xf));
652
- children.push(createElement('cellXfs', { count: String(xfNodes.length) }, xfNodes));
653
-
654
- // Cell styles (required)
655
- children.push(
656
- createElement('cellStyles', { count: '1' }, [
657
- createElement('cellStyle', { name: 'Normal', xfId: '0', builtinId: '0' }, []),
658
- ]),
659
- );
660
-
661
- const styleSheet = createElement(
662
- 'styleSheet',
663
- { xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' },
664
- children,
665
- );
666
-
667
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([styleSheet])}`;
668
- }
669
-
670
- private _buildFontNode(font: StyleFont): XmlNode {
671
- const children: XmlNode[] = [];
672
- if (font.bold) children.push(createElement('b', {}, []));
673
- if (font.italic) children.push(createElement('i', {}, []));
674
- if (font.underline) children.push(createElement('u', {}, []));
675
- if (font.strike) children.push(createElement('strike', {}, []));
676
- if (font.size) children.push(createElement('sz', { val: String(font.size) }, []));
677
- if (font.color) {
678
- const attrs: Record<string, string> = {};
679
- if (font.color.rgb) attrs.rgb = normalizeColor(font.color.rgb);
680
- if (font.color.theme) attrs.theme = font.color.theme;
681
- if (font.color.tint) attrs.tint = font.color.tint;
682
- if (font.color.indexed) attrs.indexed = font.color.indexed;
683
- if (Object.keys(attrs).length > 0) {
684
- children.push(createElement('color', attrs, []));
685
- }
686
- }
687
- if (font.name) children.push(createElement('name', { val: font.name }, []));
688
- return createElement('font', {}, children);
689
- }
690
-
691
- private _buildFillNode(fill: StyleFill): XmlNode {
692
- const patternChildren: XmlNode[] = [];
693
- if (fill.fgColor) {
694
- const attrs: Record<string, string> = {};
695
- if (fill.fgColor.rgb) attrs.rgb = normalizeColor(fill.fgColor.rgb);
696
- if (fill.fgColor.theme) attrs.theme = fill.fgColor.theme;
697
- if (fill.fgColor.tint) attrs.tint = fill.fgColor.tint;
698
- if (fill.fgColor.indexed) attrs.indexed = fill.fgColor.indexed;
699
- if (Object.keys(attrs).length > 0) {
700
- patternChildren.push(createElement('fgColor', attrs, []));
701
- }
702
- // For solid fills, bgColor is required (indexed 64 = system background)
703
- if (fill.type === 'solid' && !fill.bgColor) {
704
- patternChildren.push(createElement('bgColor', { indexed: '64' }, []));
705
- }
706
- }
707
- if (fill.bgColor) {
708
- const attrs: Record<string, string> = {};
709
- if (fill.bgColor.rgb) attrs.rgb = normalizeColor(fill.bgColor.rgb);
710
- if (fill.bgColor.theme) attrs.theme = fill.bgColor.theme;
711
- if (fill.bgColor.tint) attrs.tint = fill.bgColor.tint;
712
- if (fill.bgColor.indexed) attrs.indexed = fill.bgColor.indexed;
713
- if (Object.keys(attrs).length > 0) {
714
- patternChildren.push(createElement('bgColor', attrs, []));
715
- }
716
- }
717
- const patternFill = createElement('patternFill', { patternType: fill.type || 'none' }, patternChildren);
718
- return createElement('fill', {}, [patternFill]);
719
- }
720
-
721
- private _toStyleColor(rgb?: string, theme?: number, tint?: number, indexed?: number): StyleColor | undefined {
722
- if (rgb) {
723
- return { rgb };
724
- }
725
- const color: StyleColor = {};
726
- if (theme !== undefined) color.theme = String(theme);
727
- if (tint !== undefined) color.tint = String(tint);
728
- if (indexed !== undefined) color.indexed = String(indexed);
729
- if (color.theme || color.tint || color.indexed) return color;
730
- return undefined;
731
- }
732
-
733
- private _colorsEqual(a?: StyleColor, b?: StyleColor): boolean {
734
- if (!a && !b) return true;
735
- if (!a || !b) return false;
736
- return a.rgb === b.rgb && a.theme === b.theme && a.tint === b.tint && a.indexed === b.indexed;
737
- }
738
-
739
- private _buildBorderNode(border: StyleBorder): XmlNode {
740
- const children: XmlNode[] = [];
741
- if (border.left) children.push(createElement('left', { style: border.left }, []));
742
- if (border.right) children.push(createElement('right', { style: border.right }, []));
743
- if (border.top) children.push(createElement('top', { style: border.top }, []));
744
- if (border.bottom) children.push(createElement('bottom', { style: border.bottom }, []));
745
- // Add empty elements if not present (required by Excel)
746
- if (!border.left) children.push(createElement('left', {}, []));
747
- if (!border.right) children.push(createElement('right', {}, []));
748
- if (!border.top) children.push(createElement('top', {}, []));
749
- if (!border.bottom) children.push(createElement('bottom', {}, []));
750
- children.push(createElement('diagonal', {}, []));
751
- return createElement('border', {}, children);
752
- }
753
-
754
- private _buildXfNode(xf: CellXf): XmlNode {
755
- const attrs: Record<string, string> = {
756
- numFmtId: String(xf.numFmtId),
757
- fontId: String(xf.fontId),
758
- fillId: String(xf.fillId),
759
- borderId: String(xf.borderId),
760
- };
761
-
762
- if (xf.fontId > 0) attrs.applyFont = '1';
763
- if (xf.fillId > 0) attrs.applyFill = '1';
764
- if (xf.borderId > 0) attrs.applyBorder = '1';
765
- if (xf.numFmtId > 0) attrs.applyNumberFormat = '1';
766
-
767
- const children: XmlNode[] = [];
768
- if (xf.alignment) {
769
- const alignAttrs: Record<string, string> = {};
770
- if (xf.alignment.horizontal) alignAttrs.horizontal = xf.alignment.horizontal;
771
- if (xf.alignment.vertical) alignAttrs.vertical = xf.alignment.vertical;
772
- if (xf.alignment.wrapText) alignAttrs.wrapText = '1';
773
- if (xf.alignment.textRotation) alignAttrs.textRotation = String(xf.alignment.textRotation);
774
- children.push(createElement('alignment', alignAttrs, []));
775
- attrs.applyAlignment = '1';
776
- }
777
-
778
- return createElement('xf', attrs, children);
779
- }
780
- }
781
-
782
- // Internal types for style components
783
- interface StyleFont {
784
- bold: boolean;
785
- italic: boolean;
786
- underline: boolean;
787
- strike: boolean;
788
- size?: number;
789
- name?: string;
790
- color?: StyleColor;
791
- }
792
-
793
- interface StyleFill {
794
- type: string;
795
- fgColor?: StyleColor;
796
- bgColor?: StyleColor;
797
- }
798
-
799
- interface StyleBorder {
800
- top?: BorderType;
801
- bottom?: BorderType;
802
- left?: BorderType;
803
- right?: BorderType;
804
- }
805
-
806
- interface CellXf {
807
- fontId: number;
808
- fillId: number;
809
- borderId: number;
810
- numFmtId: number;
811
- alignment?: AlignmentStyle;
812
- }
813
-
814
- interface AlignmentStyle {
815
- horizontal?: 'left' | 'center' | 'right' | 'justify';
816
- vertical?: 'top' | 'middle' | 'bottom';
817
- wrapText?: boolean;
818
- textRotation?: number;
819
- }
1
+ import type { CellStyle, BorderType } from './types';
2
+ import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
3
+
4
+ /**
5
+ * Excel built-in number format IDs (0-163 are reserved).
6
+ * These formats don't need to be defined in the numFmts element.
7
+ */
8
+ const BUILTIN_NUM_FMTS: Map<string, number> = new Map([
9
+ ['General', 0],
10
+ ['0', 1],
11
+ ['0.00', 2],
12
+ ['#,##0', 3],
13
+ ['#,##0.00', 4],
14
+ ['0%', 9],
15
+ ['0.00%', 10],
16
+ ['0.00E+00', 11],
17
+ ['# ?/?', 12],
18
+ ['# ??/??', 13],
19
+ ['mm-dd-yy', 14],
20
+ ['d-mmm-yy', 15],
21
+ ['d-mmm', 16],
22
+ ['mmm-yy', 17],
23
+ ['h:mm AM/PM', 18],
24
+ ['h:mm:ss AM/PM', 19],
25
+ ['h:mm', 20],
26
+ ['h:mm:ss', 21],
27
+ ['m/d/yy h:mm', 22],
28
+ ['#,##0 ;(#,##0)', 37],
29
+ ['#,##0 ;[Red](#,##0)', 38],
30
+ ['#,##0.00;(#,##0.00)', 39],
31
+ ['#,##0.00;[Red](#,##0.00)', 40],
32
+ ['mm:ss', 45],
33
+ ['[h]:mm:ss', 46],
34
+ ['mmss.0', 47],
35
+ ['##0.0E+0', 48],
36
+ ['@', 49],
37
+ ]);
38
+
39
+ /**
40
+ * Reverse lookup: built-in format ID -> format code
41
+ */
42
+ const BUILTIN_NUM_FMT_CODES: Map<number, string> = new Map(
43
+ Array.from(BUILTIN_NUM_FMTS.entries()).map(([code, id]) => [id, code]),
44
+ );
45
+
46
+ /**
47
+ * Normalize a color to ARGB format (8 hex chars).
48
+ * Accepts: "#RGB", "#RRGGBB", "RGB", "RRGGBB", "AARRGGBB", "#AARRGGBB"
49
+ */
50
+ const normalizeColor = (color: string): string => {
51
+ let c = color.replace(/^#/, '').toUpperCase();
52
+
53
+ // Handle shorthand 3-char format (e.g., "FFF" -> "FFFFFF")
54
+ if (c.length === 3) {
55
+ c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
56
+ }
57
+
58
+ // Add alpha channel if not present (6 chars -> 8 chars)
59
+ if (c.length === 6) {
60
+ c = 'FF' + c;
61
+ }
62
+
63
+ return c;
64
+ };
65
+
66
+ interface StyleColor {
67
+ rgb?: string;
68
+ theme?: string;
69
+ tint?: string;
70
+ indexed?: string;
71
+ }
72
+
73
+ /**
74
+ * Manages the styles (xl/styles.xml)
75
+ */
76
+ export class Styles {
77
+ private _numFmts: Map<number, string> = new Map();
78
+ private _fonts: StyleFont[] = [];
79
+ private _fills: StyleFill[] = [];
80
+ private _borders: StyleBorder[] = [];
81
+ private _cellXfs: CellXf[] = []; // Cell formats (combined style index)
82
+ private _xmlNodes: XmlNode[] | null = null;
83
+ private _dirty = false;
84
+
85
+ // Cache for style deduplication
86
+ private _styleCache: Map<string, number> = new Map();
87
+ private _styleObjectCache: Map<number, CellStyle> = new Map();
88
+
89
+ /**
90
+ * Generate a deterministic cache key for a style object.
91
+ * More efficient than JSON.stringify as it avoids the overhead of
92
+ * full JSON serialization and produces a consistent key regardless
93
+ * of property order.
94
+ */
95
+ private _getStyleKey(style: CellStyle): string {
96
+ // Use a delimiter that won't appear in values
97
+ const SEP = '\x00';
98
+
99
+ // Build key from all style properties in a fixed order
100
+ const parts: string[] = [
101
+ style.bold ? '1' : '0',
102
+ style.italic ? '1' : '0',
103
+ style.underline === true ? '1' : style.underline === 'single' ? 's' : style.underline === 'double' ? 'd' : '0',
104
+ style.strike ? '1' : '0',
105
+ style.fontSize?.toString() ?? '',
106
+ style.fontName ?? '',
107
+ style.fontColor ?? '',
108
+ style.fontColorTheme?.toString() ?? '',
109
+ style.fontColorTint?.toString() ?? '',
110
+ style.fontColorIndexed?.toString() ?? '',
111
+ style.fill ?? '',
112
+ style.fillTheme?.toString() ?? '',
113
+ style.fillTint?.toString() ?? '',
114
+ style.fillIndexed?.toString() ?? '',
115
+ style.fillBgColor ?? '',
116
+ style.fillBgTheme?.toString() ?? '',
117
+ style.fillBgTint?.toString() ?? '',
118
+ style.fillBgIndexed?.toString() ?? '',
119
+ style.numberFormat ?? '',
120
+ ];
121
+
122
+ // Border properties
123
+ if (style.border) {
124
+ parts.push(style.border.top ?? '', style.border.bottom ?? '', style.border.left ?? '', style.border.right ?? '');
125
+ } else {
126
+ parts.push('', '', '', '');
127
+ }
128
+
129
+ // Alignment properties
130
+ if (style.alignment) {
131
+ parts.push(
132
+ style.alignment.horizontal ?? '',
133
+ style.alignment.vertical ?? '',
134
+ style.alignment.wrapText ? '1' : '0',
135
+ style.alignment.textRotation?.toString() ?? '',
136
+ );
137
+ } else {
138
+ parts.push('', '', '0', '');
139
+ }
140
+
141
+ return parts.join(SEP);
142
+ }
143
+
144
+ /**
145
+ * Parse styles from XML content
146
+ */
147
+ static parse(xml: string): Styles {
148
+ const styles = new Styles();
149
+ styles._xmlNodes = parseXml(xml);
150
+
151
+ const styleSheet = findElement(styles._xmlNodes, 'styleSheet');
152
+ if (!styleSheet) return styles;
153
+
154
+ const children = getChildren(styleSheet, 'styleSheet');
155
+
156
+ // Parse number formats
157
+ const numFmts = findElement(children, 'numFmts');
158
+ if (numFmts) {
159
+ for (const child of getChildren(numFmts, 'numFmts')) {
160
+ if ('numFmt' in child) {
161
+ const id = parseInt(getAttr(child, 'numFmtId') || '0', 10);
162
+ const code = getAttr(child, 'formatCode') || '';
163
+ styles._numFmts.set(id, code);
164
+ }
165
+ }
166
+ }
167
+
168
+ // Parse fonts
169
+ const fonts = findElement(children, 'fonts');
170
+ if (fonts) {
171
+ for (const child of getChildren(fonts, 'fonts')) {
172
+ if ('font' in child) {
173
+ styles._fonts.push(styles._parseFont(child));
174
+ }
175
+ }
176
+ }
177
+
178
+ // Parse fills
179
+ const fills = findElement(children, 'fills');
180
+ if (fills) {
181
+ for (const child of getChildren(fills, 'fills')) {
182
+ if ('fill' in child) {
183
+ styles._fills.push(styles._parseFill(child));
184
+ }
185
+ }
186
+ }
187
+
188
+ // Parse borders
189
+ const borders = findElement(children, 'borders');
190
+ if (borders) {
191
+ for (const child of getChildren(borders, 'borders')) {
192
+ if ('border' in child) {
193
+ styles._borders.push(styles._parseBorder(child));
194
+ }
195
+ }
196
+ }
197
+
198
+ // Parse cellXfs (cell formats)
199
+ const cellXfs = findElement(children, 'cellXfs');
200
+ if (cellXfs) {
201
+ for (const child of getChildren(cellXfs, 'cellXfs')) {
202
+ if ('xf' in child) {
203
+ styles._cellXfs.push(styles._parseCellXf(child));
204
+ }
205
+ }
206
+ }
207
+
208
+ return styles;
209
+ }
210
+
211
+ /**
212
+ * Create an empty styles object with defaults
213
+ */
214
+ static createDefault(): Styles {
215
+ const styles = new Styles();
216
+
217
+ // Default font (Calibri 11)
218
+ styles._fonts.push({
219
+ bold: false,
220
+ italic: false,
221
+ underline: false,
222
+ strike: false,
223
+ size: 11,
224
+ name: 'Calibri',
225
+ color: undefined,
226
+ });
227
+
228
+ // Default fills (none and gray125 pattern are required)
229
+ styles._fills.push({ type: 'none' });
230
+ styles._fills.push({ type: 'gray125' });
231
+
232
+ // Default border (none)
233
+ styles._borders.push({});
234
+
235
+ // Default cell format
236
+ styles._cellXfs.push({
237
+ fontId: 0,
238
+ fillId: 0,
239
+ borderId: 0,
240
+ numFmtId: 0,
241
+ });
242
+
243
+ return styles;
244
+ }
245
+
246
+ private _parseFont(node: XmlNode): StyleFont {
247
+ const font: StyleFont = {
248
+ bold: false,
249
+ italic: false,
250
+ underline: false,
251
+ strike: false,
252
+ };
253
+
254
+ const children = getChildren(node, 'font');
255
+ for (const child of children) {
256
+ if ('b' in child) font.bold = true;
257
+ if ('i' in child) font.italic = true;
258
+ if ('u' in child) font.underline = true;
259
+ if ('strike' in child) font.strike = true;
260
+ if ('sz' in child) font.size = parseFloat(getAttr(child, 'val') || '11');
261
+ if ('name' in child) font.name = getAttr(child, 'val');
262
+ if ('color' in child) {
263
+ const color: StyleColor = {};
264
+ const rgb = getAttr(child, 'rgb');
265
+ const theme = getAttr(child, 'theme');
266
+ const tint = getAttr(child, 'tint');
267
+ const indexed = getAttr(child, 'indexed');
268
+ if (rgb) color.rgb = rgb;
269
+ if (theme) color.theme = theme;
270
+ if (tint) color.tint = tint;
271
+ if (indexed) color.indexed = indexed;
272
+ if (color.rgb || color.theme || color.tint || color.indexed) {
273
+ font.color = color;
274
+ }
275
+ }
276
+ }
277
+
278
+ return font;
279
+ }
280
+
281
+ private _parseFill(node: XmlNode): StyleFill {
282
+ const fill: StyleFill = { type: 'none' };
283
+ const children = getChildren(node, 'fill');
284
+
285
+ for (const child of children) {
286
+ if ('patternFill' in child) {
287
+ const pattern = getAttr(child, 'patternType');
288
+ fill.type = pattern || 'none';
289
+
290
+ const pfChildren = getChildren(child, 'patternFill');
291
+ for (const pfChild of pfChildren) {
292
+ if ('fgColor' in pfChild) {
293
+ const color: StyleColor = {};
294
+ const rgb = getAttr(pfChild, 'rgb');
295
+ const theme = getAttr(pfChild, 'theme');
296
+ const tint = getAttr(pfChild, 'tint');
297
+ const indexed = getAttr(pfChild, 'indexed');
298
+ if (rgb) color.rgb = rgb;
299
+ if (theme) color.theme = theme;
300
+ if (tint) color.tint = tint;
301
+ if (indexed) color.indexed = indexed;
302
+ if (color.rgb || color.theme || color.tint || color.indexed) {
303
+ fill.fgColor = color;
304
+ }
305
+ }
306
+ if ('bgColor' in pfChild) {
307
+ const color: StyleColor = {};
308
+ const rgb = getAttr(pfChild, 'rgb');
309
+ const theme = getAttr(pfChild, 'theme');
310
+ const tint = getAttr(pfChild, 'tint');
311
+ const indexed = getAttr(pfChild, 'indexed');
312
+ if (rgb) color.rgb = rgb;
313
+ if (theme) color.theme = theme;
314
+ if (tint) color.tint = tint;
315
+ if (indexed) color.indexed = indexed;
316
+ if (color.rgb || color.theme || color.tint || color.indexed) {
317
+ fill.bgColor = color;
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ return fill;
325
+ }
326
+
327
+ private _parseBorder(node: XmlNode): StyleBorder {
328
+ const border: StyleBorder = {};
329
+ const children = getChildren(node, 'border');
330
+
331
+ for (const child of children) {
332
+ const style = getAttr(child, 'style') as BorderType | undefined;
333
+ if ('left' in child && style) border.left = style;
334
+ if ('right' in child && style) border.right = style;
335
+ if ('top' in child && style) border.top = style;
336
+ if ('bottom' in child && style) border.bottom = style;
337
+ }
338
+
339
+ return border;
340
+ }
341
+
342
+ private _parseCellXf(node: XmlNode): CellXf {
343
+ return {
344
+ fontId: parseInt(getAttr(node, 'fontId') || '0', 10),
345
+ fillId: parseInt(getAttr(node, 'fillId') || '0', 10),
346
+ borderId: parseInt(getAttr(node, 'borderId') || '0', 10),
347
+ numFmtId: parseInt(getAttr(node, 'numFmtId') || '0', 10),
348
+ alignment: this._parseAlignment(node),
349
+ };
350
+ }
351
+
352
+ private _parseAlignment(node: XmlNode): AlignmentStyle | undefined {
353
+ const children = getChildren(node, 'xf');
354
+ const alignNode = findElement(children, 'alignment');
355
+ if (!alignNode) return undefined;
356
+
357
+ return {
358
+ horizontal: getAttr(alignNode, 'horizontal') as AlignmentStyle['horizontal'],
359
+ vertical: getAttr(alignNode, 'vertical') as AlignmentStyle['vertical'],
360
+ wrapText: getAttr(alignNode, 'wrapText') === '1',
361
+ textRotation: parseInt(getAttr(alignNode, 'textRotation') || '0', 10),
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Get a style by index
367
+ */
368
+ getStyle(index: number): CellStyle {
369
+ const cached = this._styleObjectCache.get(index);
370
+ if (cached) return { ...cached };
371
+
372
+ const xf = this._cellXfs[index];
373
+ if (!xf) return {};
374
+
375
+ const font = this._fonts[xf.fontId];
376
+ const fill = this._fills[xf.fillId];
377
+ const border = this._borders[xf.borderId];
378
+ // Check custom formats first, then fall back to built-in format codes
379
+ const numFmt = this._numFmts.get(xf.numFmtId) ?? BUILTIN_NUM_FMT_CODES.get(xf.numFmtId);
380
+
381
+ const style: CellStyle = {};
382
+
383
+ if (font) {
384
+ if (font.bold) style.bold = true;
385
+ if (font.italic) style.italic = true;
386
+ if (font.underline) style.underline = true;
387
+ if (font.strike) style.strike = true;
388
+ if (font.size) style.fontSize = font.size;
389
+ if (font.name) style.fontName = font.name;
390
+ if (font.color?.rgb) style.fontColor = font.color.rgb;
391
+ if (font.color?.theme) style.fontColorTheme = Number(font.color.theme);
392
+ if (font.color?.tint) style.fontColorTint = Number(font.color.tint);
393
+ if (font.color?.indexed) style.fontColorIndexed = Number(font.color.indexed);
394
+ }
395
+
396
+ if (fill && fill.fgColor) {
397
+ if (fill.fgColor.rgb) style.fill = fill.fgColor.rgb;
398
+ if (fill.fgColor.theme) style.fillTheme = Number(fill.fgColor.theme);
399
+ if (fill.fgColor.tint) style.fillTint = Number(fill.fgColor.tint);
400
+ if (fill.fgColor.indexed) style.fillIndexed = Number(fill.fgColor.indexed);
401
+ }
402
+
403
+ if (fill && fill.bgColor) {
404
+ if (fill.bgColor.rgb) style.fillBgColor = fill.bgColor.rgb;
405
+ if (fill.bgColor.theme) style.fillBgTheme = Number(fill.bgColor.theme);
406
+ if (fill.bgColor.tint) style.fillBgTint = Number(fill.bgColor.tint);
407
+ if (fill.bgColor.indexed) style.fillBgIndexed = Number(fill.bgColor.indexed);
408
+ }
409
+
410
+ if (border) {
411
+ if (border.top || border.bottom || border.left || border.right) {
412
+ style.border = {
413
+ top: border.top,
414
+ bottom: border.bottom,
415
+ left: border.left,
416
+ right: border.right,
417
+ };
418
+ }
419
+ }
420
+
421
+ if (numFmt) {
422
+ style.numberFormat = numFmt;
423
+ }
424
+
425
+ if (xf.alignment) {
426
+ style.alignment = {
427
+ horizontal: xf.alignment.horizontal,
428
+ vertical: xf.alignment.vertical,
429
+ wrapText: xf.alignment.wrapText,
430
+ textRotation: xf.alignment.textRotation,
431
+ };
432
+ }
433
+
434
+ this._styleObjectCache.set(index, { ...style });
435
+ return style;
436
+ }
437
+
438
+ /**
439
+ * Create a style and return its index
440
+ * Uses caching to deduplicate identical styles
441
+ */
442
+ createStyle(style: CellStyle): number {
443
+ const key = this._getStyleKey(style);
444
+ const cached = this._styleCache.get(key);
445
+ if (cached !== undefined) {
446
+ return cached;
447
+ }
448
+
449
+ this._dirty = true;
450
+
451
+ // Create or find font
452
+ const fontId = this._findOrCreateFont(style);
453
+
454
+ // Create or find fill
455
+ const fillId = this._findOrCreateFill(style);
456
+
457
+ // Create or find border
458
+ const borderId = this._findOrCreateBorder(style);
459
+
460
+ // Create or find number format
461
+ const numFmtId = style.numberFormat ? this._findOrCreateNumFmt(style.numberFormat) : 0;
462
+
463
+ // Create cell format
464
+ const xf: CellXf = {
465
+ fontId,
466
+ fillId,
467
+ borderId,
468
+ numFmtId,
469
+ };
470
+
471
+ if (style.alignment) {
472
+ xf.alignment = {
473
+ horizontal: style.alignment.horizontal,
474
+ vertical: style.alignment.vertical,
475
+ wrapText: style.alignment.wrapText,
476
+ textRotation: style.alignment.textRotation,
477
+ };
478
+ }
479
+
480
+ const index = this._cellXfs.length;
481
+ this._cellXfs.push(xf);
482
+ this._styleCache.set(key, index);
483
+ this._styleObjectCache.set(index, { ...style });
484
+
485
+ return index;
486
+ }
487
+
488
+ /**
489
+ * Clone an existing style by index, optionally overriding fields.
490
+ */
491
+ cloneStyle(index: number, overrides: Partial<CellStyle> = {}): number {
492
+ const baseStyle = this.getStyle(index);
493
+ return this.createStyle({ ...baseStyle, ...overrides });
494
+ }
495
+
496
+ private _findOrCreateFont(style: CellStyle): number {
497
+ const color = this._toStyleColor(
498
+ style.fontColor,
499
+ style.fontColorTheme,
500
+ style.fontColorTint,
501
+ style.fontColorIndexed,
502
+ );
503
+ const font: StyleFont = {
504
+ bold: style.bold || false,
505
+ italic: style.italic || false,
506
+ underline: style.underline === true || style.underline === 'single' || style.underline === 'double',
507
+ strike: style.strike || false,
508
+ size: style.fontSize,
509
+ name: style.fontName,
510
+ color,
511
+ };
512
+
513
+ // Try to find existing font
514
+ for (let i = 0; i < this._fonts.length; i++) {
515
+ const f = this._fonts[i];
516
+ if (
517
+ f.bold === font.bold &&
518
+ f.italic === font.italic &&
519
+ f.underline === font.underline &&
520
+ f.strike === font.strike &&
521
+ f.size === font.size &&
522
+ f.name === font.name &&
523
+ this._colorsEqual(f.color, font.color)
524
+ ) {
525
+ return i;
526
+ }
527
+ }
528
+
529
+ // Create new font
530
+ this._fonts.push(font);
531
+ return this._fonts.length - 1;
532
+ }
533
+
534
+ private _findOrCreateFill(style: CellStyle): number {
535
+ const fgColor = this._toStyleColor(style.fill, style.fillTheme, style.fillTint, style.fillIndexed);
536
+ const bgColor = this._toStyleColor(style.fillBgColor, style.fillBgTheme, style.fillBgTint, style.fillBgIndexed);
537
+
538
+ if (!fgColor && !bgColor) return 0;
539
+
540
+ // Try to find existing fill
541
+ for (let i = 0; i < this._fills.length; i++) {
542
+ const f = this._fills[i];
543
+ if (this._colorsEqual(f.fgColor, fgColor) && this._colorsEqual(f.bgColor, bgColor)) {
544
+ return i;
545
+ }
546
+ }
547
+
548
+ // Create new fill
549
+ this._fills.push({
550
+ type: 'solid',
551
+ fgColor: fgColor || undefined,
552
+ bgColor: bgColor || undefined,
553
+ });
554
+ return this._fills.length - 1;
555
+ }
556
+
557
+ private _findOrCreateBorder(style: CellStyle): number {
558
+ if (!style.border) return 0;
559
+
560
+ const border: StyleBorder = {
561
+ top: style.border.top,
562
+ bottom: style.border.bottom,
563
+ left: style.border.left,
564
+ right: style.border.right,
565
+ };
566
+
567
+ // Try to find existing border
568
+ for (let i = 0; i < this._borders.length; i++) {
569
+ const b = this._borders[i];
570
+ if (b.top === border.top && b.bottom === border.bottom && b.left === border.left && b.right === border.right) {
571
+ return i;
572
+ }
573
+ }
574
+
575
+ // Create new border
576
+ this._borders.push(border);
577
+ return this._borders.length - 1;
578
+ }
579
+
580
+ private _findOrCreateNumFmt(format: string): number {
581
+ // Check built-in formats first (IDs 0-163)
582
+ const builtinId = BUILTIN_NUM_FMTS.get(format);
583
+ if (builtinId !== undefined) {
584
+ return builtinId;
585
+ }
586
+
587
+ // Check if already exists in custom formats
588
+ for (const [id, code] of this._numFmts) {
589
+ if (code === format) return id;
590
+ }
591
+
592
+ // Create new custom format (IDs 164+)
593
+ const existingIds = Array.from(this._numFmts.keys());
594
+ const id = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 164;
595
+ this._numFmts.set(id, format);
596
+ return id;
597
+ }
598
+
599
+ /**
600
+ * Get or create a number format ID for the given format string.
601
+ * Returns built-in IDs (0-163) for standard formats, or creates custom IDs (164+).
602
+ * @param format - The number format string (e.g., '0.00', '#,##0', '$#,##0.00')
603
+ */
604
+ getOrCreateNumFmtId(format: string): number {
605
+ this._dirty = true;
606
+ return this._findOrCreateNumFmt(format);
607
+ }
608
+
609
+ /**
610
+ * Check if styles have been modified
611
+ */
612
+ get dirty(): boolean {
613
+ return this._dirty;
614
+ }
615
+
616
+ /**
617
+ * Generate XML for styles
618
+ */
619
+ toXml(): string {
620
+ const children: XmlNode[] = [];
621
+
622
+ // Number formats
623
+ if (this._numFmts.size > 0) {
624
+ const numFmtNodes: XmlNode[] = [];
625
+ for (const [id, code] of this._numFmts) {
626
+ numFmtNodes.push(createElement('numFmt', { numFmtId: String(id), formatCode: code }, []));
627
+ }
628
+ children.push(createElement('numFmts', { count: String(numFmtNodes.length) }, numFmtNodes));
629
+ }
630
+
631
+ // Fonts
632
+ const fontNodes: XmlNode[] = this._fonts.map((font) => this._buildFontNode(font));
633
+ children.push(createElement('fonts', { count: String(fontNodes.length) }, fontNodes));
634
+
635
+ // Fills
636
+ const fillNodes: XmlNode[] = this._fills.map((fill) => this._buildFillNode(fill));
637
+ children.push(createElement('fills', { count: String(fillNodes.length) }, fillNodes));
638
+
639
+ // Borders
640
+ const borderNodes: XmlNode[] = this._borders.map((border) => this._buildBorderNode(border));
641
+ children.push(createElement('borders', { count: String(borderNodes.length) }, borderNodes));
642
+
643
+ // Cell style xfs (required but we just add a default)
644
+ children.push(
645
+ createElement('cellStyleXfs', { count: '1' }, [
646
+ createElement('xf', { numFmtId: '0', fontId: '0', fillId: '0', borderId: '0' }, []),
647
+ ]),
648
+ );
649
+
650
+ // Cell xfs
651
+ const xfNodes: XmlNode[] = this._cellXfs.map((xf) => this._buildXfNode(xf));
652
+ children.push(createElement('cellXfs', { count: String(xfNodes.length) }, xfNodes));
653
+
654
+ // Cell styles (required)
655
+ children.push(
656
+ createElement('cellStyles', { count: '1' }, [
657
+ createElement('cellStyle', { name: 'Normal', xfId: '0', builtinId: '0' }, []),
658
+ ]),
659
+ );
660
+
661
+ const styleSheet = createElement(
662
+ 'styleSheet',
663
+ { xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' },
664
+ children,
665
+ );
666
+
667
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([styleSheet])}`;
668
+ }
669
+
670
+ private _buildFontNode(font: StyleFont): XmlNode {
671
+ const children: XmlNode[] = [];
672
+ if (font.bold) children.push(createElement('b', {}, []));
673
+ if (font.italic) children.push(createElement('i', {}, []));
674
+ if (font.underline) children.push(createElement('u', {}, []));
675
+ if (font.strike) children.push(createElement('strike', {}, []));
676
+ if (font.size) children.push(createElement('sz', { val: String(font.size) }, []));
677
+ if (font.color) {
678
+ const attrs: Record<string, string> = {};
679
+ if (font.color.rgb) attrs.rgb = normalizeColor(font.color.rgb);
680
+ if (font.color.theme) attrs.theme = font.color.theme;
681
+ if (font.color.tint) attrs.tint = font.color.tint;
682
+ if (font.color.indexed) attrs.indexed = font.color.indexed;
683
+ if (Object.keys(attrs).length > 0) {
684
+ children.push(createElement('color', attrs, []));
685
+ }
686
+ }
687
+ if (font.name) children.push(createElement('name', { val: font.name }, []));
688
+ return createElement('font', {}, children);
689
+ }
690
+
691
+ private _buildFillNode(fill: StyleFill): XmlNode {
692
+ const patternChildren: XmlNode[] = [];
693
+ if (fill.fgColor) {
694
+ const attrs: Record<string, string> = {};
695
+ if (fill.fgColor.rgb) attrs.rgb = normalizeColor(fill.fgColor.rgb);
696
+ if (fill.fgColor.theme) attrs.theme = fill.fgColor.theme;
697
+ if (fill.fgColor.tint) attrs.tint = fill.fgColor.tint;
698
+ if (fill.fgColor.indexed) attrs.indexed = fill.fgColor.indexed;
699
+ if (Object.keys(attrs).length > 0) {
700
+ patternChildren.push(createElement('fgColor', attrs, []));
701
+ }
702
+ // For solid fills, bgColor is required (indexed 64 = system background)
703
+ if (fill.type === 'solid' && !fill.bgColor) {
704
+ patternChildren.push(createElement('bgColor', { indexed: '64' }, []));
705
+ }
706
+ }
707
+ if (fill.bgColor) {
708
+ const attrs: Record<string, string> = {};
709
+ if (fill.bgColor.rgb) attrs.rgb = normalizeColor(fill.bgColor.rgb);
710
+ if (fill.bgColor.theme) attrs.theme = fill.bgColor.theme;
711
+ if (fill.bgColor.tint) attrs.tint = fill.bgColor.tint;
712
+ if (fill.bgColor.indexed) attrs.indexed = fill.bgColor.indexed;
713
+ if (Object.keys(attrs).length > 0) {
714
+ patternChildren.push(createElement('bgColor', attrs, []));
715
+ }
716
+ }
717
+ const patternFill = createElement('patternFill', { patternType: fill.type || 'none' }, patternChildren);
718
+ return createElement('fill', {}, [patternFill]);
719
+ }
720
+
721
+ private _toStyleColor(rgb?: string, theme?: number, tint?: number, indexed?: number): StyleColor | undefined {
722
+ if (rgb) {
723
+ return { rgb };
724
+ }
725
+ const color: StyleColor = {};
726
+ if (theme !== undefined) color.theme = String(theme);
727
+ if (tint !== undefined) color.tint = String(tint);
728
+ if (indexed !== undefined) color.indexed = String(indexed);
729
+ if (color.theme || color.tint || color.indexed) return color;
730
+ return undefined;
731
+ }
732
+
733
+ private _colorsEqual(a?: StyleColor, b?: StyleColor): boolean {
734
+ if (!a && !b) return true;
735
+ if (!a || !b) return false;
736
+ return a.rgb === b.rgb && a.theme === b.theme && a.tint === b.tint && a.indexed === b.indexed;
737
+ }
738
+
739
+ private _buildBorderNode(border: StyleBorder): XmlNode {
740
+ const children: XmlNode[] = [];
741
+ if (border.left) children.push(createElement('left', { style: border.left }, []));
742
+ if (border.right) children.push(createElement('right', { style: border.right }, []));
743
+ if (border.top) children.push(createElement('top', { style: border.top }, []));
744
+ if (border.bottom) children.push(createElement('bottom', { style: border.bottom }, []));
745
+ // Add empty elements if not present (required by Excel)
746
+ if (!border.left) children.push(createElement('left', {}, []));
747
+ if (!border.right) children.push(createElement('right', {}, []));
748
+ if (!border.top) children.push(createElement('top', {}, []));
749
+ if (!border.bottom) children.push(createElement('bottom', {}, []));
750
+ children.push(createElement('diagonal', {}, []));
751
+ return createElement('border', {}, children);
752
+ }
753
+
754
+ private _buildXfNode(xf: CellXf): XmlNode {
755
+ const attrs: Record<string, string> = {
756
+ numFmtId: String(xf.numFmtId),
757
+ fontId: String(xf.fontId),
758
+ fillId: String(xf.fillId),
759
+ borderId: String(xf.borderId),
760
+ };
761
+
762
+ if (xf.fontId > 0) attrs.applyFont = '1';
763
+ if (xf.fillId > 0) attrs.applyFill = '1';
764
+ if (xf.borderId > 0) attrs.applyBorder = '1';
765
+ if (xf.numFmtId > 0) attrs.applyNumberFormat = '1';
766
+
767
+ const children: XmlNode[] = [];
768
+ if (xf.alignment) {
769
+ const alignAttrs: Record<string, string> = {};
770
+ if (xf.alignment.horizontal) alignAttrs.horizontal = xf.alignment.horizontal;
771
+ if (xf.alignment.vertical) alignAttrs.vertical = xf.alignment.vertical;
772
+ if (xf.alignment.wrapText) alignAttrs.wrapText = '1';
773
+ if (xf.alignment.textRotation) alignAttrs.textRotation = String(xf.alignment.textRotation);
774
+ children.push(createElement('alignment', alignAttrs, []));
775
+ attrs.applyAlignment = '1';
776
+ }
777
+
778
+ return createElement('xf', attrs, children);
779
+ }
780
+ }
781
+
782
+ // Internal types for style components
783
+ interface StyleFont {
784
+ bold: boolean;
785
+ italic: boolean;
786
+ underline: boolean;
787
+ strike: boolean;
788
+ size?: number;
789
+ name?: string;
790
+ color?: StyleColor;
791
+ }
792
+
793
+ interface StyleFill {
794
+ type: string;
795
+ fgColor?: StyleColor;
796
+ bgColor?: StyleColor;
797
+ }
798
+
799
+ interface StyleBorder {
800
+ top?: BorderType;
801
+ bottom?: BorderType;
802
+ left?: BorderType;
803
+ right?: BorderType;
804
+ }
805
+
806
+ interface CellXf {
807
+ fontId: number;
808
+ fillId: number;
809
+ borderId: number;
810
+ numFmtId: number;
811
+ alignment?: AlignmentStyle;
812
+ }
813
+
814
+ interface AlignmentStyle {
815
+ horizontal?: 'left' | 'center' | 'right' | 'justify';
816
+ vertical?: 'top' | 'middle' | 'bottom';
817
+ wrapText?: boolean;
818
+ textRotation?: number;
819
+ }