@niicojs/excel 0.2.6 → 0.3.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.
package/src/styles.ts CHANGED
@@ -63,6 +63,13 @@ const normalizeColor = (color: string): string => {
63
63
  return c;
64
64
  };
65
65
 
66
+ interface StyleColor {
67
+ rgb?: string;
68
+ theme?: string;
69
+ tint?: string;
70
+ indexed?: string;
71
+ }
72
+
66
73
  /**
67
74
  * Manages the styles (xl/styles.xml)
68
75
  */
@@ -77,6 +84,62 @@ export class Styles {
77
84
 
78
85
  // Cache for style deduplication
79
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
+ }
80
143
 
81
144
  /**
82
145
  * Parse styles from XML content
@@ -197,7 +260,18 @@ export class Styles {
197
260
  if ('sz' in child) font.size = parseFloat(getAttr(child, 'val') || '11');
198
261
  if ('name' in child) font.name = getAttr(child, 'val');
199
262
  if ('color' in child) {
200
- font.color = getAttr(child, 'rgb') || getAttr(child, 'theme');
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
+ }
201
275
  }
202
276
  }
203
277
 
@@ -216,10 +290,32 @@ export class Styles {
216
290
  const pfChildren = getChildren(child, 'patternFill');
217
291
  for (const pfChild of pfChildren) {
218
292
  if ('fgColor' in pfChild) {
219
- fill.fgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
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
+ }
220
305
  }
221
306
  if ('bgColor' in pfChild) {
222
- fill.bgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
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
+ }
223
319
  }
224
320
  }
225
321
  }
@@ -270,6 +366,9 @@ export class Styles {
270
366
  * Get a style by index
271
367
  */
272
368
  getStyle(index: number): CellStyle {
369
+ const cached = this._styleObjectCache.get(index);
370
+ if (cached) return { ...cached };
371
+
273
372
  const xf = this._cellXfs[index];
274
373
  if (!xf) return {};
275
374
 
@@ -288,11 +387,24 @@ export class Styles {
288
387
  if (font.strike) style.strike = true;
289
388
  if (font.size) style.fontSize = font.size;
290
389
  if (font.name) style.fontName = font.name;
291
- if (font.color) style.fontColor = font.color;
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);
292
394
  }
293
395
 
294
396
  if (fill && fill.fgColor) {
295
- style.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);
296
408
  }
297
409
 
298
410
  if (border) {
@@ -319,6 +431,7 @@ export class Styles {
319
431
  };
320
432
  }
321
433
 
434
+ this._styleObjectCache.set(index, { ...style });
322
435
  return style;
323
436
  }
324
437
 
@@ -327,7 +440,7 @@ export class Styles {
327
440
  * Uses caching to deduplicate identical styles
328
441
  */
329
442
  createStyle(style: CellStyle): number {
330
- const key = JSON.stringify(style);
443
+ const key = this._getStyleKey(style);
331
444
  const cached = this._styleCache.get(key);
332
445
  if (cached !== undefined) {
333
446
  return cached;
@@ -367,11 +480,26 @@ export class Styles {
367
480
  const index = this._cellXfs.length;
368
481
  this._cellXfs.push(xf);
369
482
  this._styleCache.set(key, index);
483
+ this._styleObjectCache.set(index, { ...style });
370
484
 
371
485
  return index;
372
486
  }
373
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
+
374
496
  private _findOrCreateFont(style: CellStyle): number {
497
+ const color = this._toStyleColor(
498
+ style.fontColor,
499
+ style.fontColorTheme,
500
+ style.fontColorTint,
501
+ style.fontColorIndexed,
502
+ );
375
503
  const font: StyleFont = {
376
504
  bold: style.bold || false,
377
505
  italic: style.italic || false,
@@ -379,7 +507,7 @@ export class Styles {
379
507
  strike: style.strike || false,
380
508
  size: style.fontSize,
381
509
  name: style.fontName,
382
- color: style.fontColor,
510
+ color,
383
511
  };
384
512
 
385
513
  // Try to find existing font
@@ -392,7 +520,7 @@ export class Styles {
392
520
  f.strike === font.strike &&
393
521
  f.size === font.size &&
394
522
  f.name === font.name &&
395
- f.color === font.color
523
+ this._colorsEqual(f.color, font.color)
396
524
  ) {
397
525
  return i;
398
526
  }
@@ -404,12 +532,15 @@ export class Styles {
404
532
  }
405
533
 
406
534
  private _findOrCreateFill(style: CellStyle): number {
407
- if (!style.fill) return 0;
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;
408
539
 
409
540
  // Try to find existing fill
410
541
  for (let i = 0; i < this._fills.length; i++) {
411
542
  const f = this._fills[i];
412
- if (f.fgColor === style.fill) {
543
+ if (this._colorsEqual(f.fgColor, fgColor) && this._colorsEqual(f.bgColor, bgColor)) {
413
544
  return i;
414
545
  }
415
546
  }
@@ -417,7 +548,8 @@ export class Styles {
417
548
  // Create new fill
418
549
  this._fills.push({
419
550
  type: 'solid',
420
- fgColor: style.fill,
551
+ fgColor: fgColor || undefined,
552
+ bgColor: bgColor || undefined,
421
553
  });
422
554
  return this._fills.length - 1;
423
555
  }
@@ -542,7 +674,16 @@ export class Styles {
542
674
  if (font.underline) children.push(createElement('u', {}, []));
543
675
  if (font.strike) children.push(createElement('strike', {}, []));
544
676
  if (font.size) children.push(createElement('sz', { val: String(font.size) }, []));
545
- if (font.color) children.push(createElement('color', { rgb: normalizeColor(font.color) }, []));
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
+ }
546
687
  if (font.name) children.push(createElement('name', { val: font.name }, []));
547
688
  return createElement('font', {}, children);
548
689
  }
@@ -550,21 +691,51 @@ export class Styles {
550
691
  private _buildFillNode(fill: StyleFill): XmlNode {
551
692
  const patternChildren: XmlNode[] = [];
552
693
  if (fill.fgColor) {
553
- const rgb = normalizeColor(fill.fgColor);
554
- patternChildren.push(createElement('fgColor', { rgb }, []));
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
+ }
555
702
  // For solid fills, bgColor is required (indexed 64 = system background)
556
- if (fill.type === 'solid') {
703
+ if (fill.type === 'solid' && !fill.bgColor) {
557
704
  patternChildren.push(createElement('bgColor', { indexed: '64' }, []));
558
705
  }
559
706
  }
560
- if (fill.bgColor && fill.type !== 'solid') {
561
- const rgb = normalizeColor(fill.bgColor);
562
- patternChildren.push(createElement('bgColor', { rgb }, []));
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
+ }
563
716
  }
564
717
  const patternFill = createElement('patternFill', { patternType: fill.type || 'none' }, patternChildren);
565
718
  return createElement('fill', {}, [patternFill]);
566
719
  }
567
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
+
568
739
  private _buildBorderNode(border: StyleBorder): XmlNode {
569
740
  const children: XmlNode[] = [];
570
741
  if (border.left) children.push(createElement('left', { style: border.left }, []));
@@ -616,13 +787,13 @@ interface StyleFont {
616
787
  strike: boolean;
617
788
  size?: number;
618
789
  name?: string;
619
- color?: string;
790
+ color?: StyleColor;
620
791
  }
621
792
 
622
793
  interface StyleFill {
623
794
  type: string;
624
- fgColor?: string;
625
- bgColor?: string;
795
+ fgColor?: StyleColor;
796
+ bgColor?: StyleColor;
626
797
  }
627
798
 
628
799
  interface StyleBorder {