@niicojs/excel 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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/dist/index.cjs +2894 -0
- package/dist/index.d.cts +745 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +745 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2881 -0
- package/package.json +61 -0
- package/src/cell.ts +318 -0
- package/src/index.ts +31 -0
- package/src/pivot-cache.ts +268 -0
- package/src/pivot-table.ts +523 -0
- package/src/range.ts +141 -0
- package/src/shared-strings.ts +129 -0
- package/src/styles.ts +588 -0
- package/src/types.ts +165 -0
- package/src/utils/address.ts +118 -0
- package/src/utils/xml.ts +147 -0
- package/src/utils/zip.ts +61 -0
- package/src/workbook.ts +845 -0
- package/src/worksheet.ts +372 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { parseXml, findElement, getChildren, XmlNode, stringifyXml, createElement, createText } from './utils/xml';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manages the shared strings table from xl/sharedStrings.xml
|
|
5
|
+
* Excel stores strings in a shared table to reduce file size
|
|
6
|
+
*/
|
|
7
|
+
export class SharedStrings {
|
|
8
|
+
private strings: string[] = [];
|
|
9
|
+
private stringToIndex: Map<string, number> = new Map();
|
|
10
|
+
private _dirty = false;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse shared strings from XML content
|
|
14
|
+
*/
|
|
15
|
+
static parse(xml: string): SharedStrings {
|
|
16
|
+
const ss = new SharedStrings();
|
|
17
|
+
const parsed = parseXml(xml);
|
|
18
|
+
const sst = findElement(parsed, 'sst');
|
|
19
|
+
if (!sst) return ss;
|
|
20
|
+
|
|
21
|
+
const children = getChildren(sst, 'sst');
|
|
22
|
+
for (const child of children) {
|
|
23
|
+
if ('si' in child) {
|
|
24
|
+
const siChildren = getChildren(child, 'si');
|
|
25
|
+
const text = ss.extractText(siChildren);
|
|
26
|
+
ss.strings.push(text);
|
|
27
|
+
ss.stringToIndex.set(text, ss.strings.length - 1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return ss;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract text from a string item (si element)
|
|
36
|
+
* Handles both simple <t> elements and rich text <r> elements
|
|
37
|
+
*/
|
|
38
|
+
private extractText(nodes: XmlNode[]): string {
|
|
39
|
+
let text = '';
|
|
40
|
+
for (const node of nodes) {
|
|
41
|
+
if ('t' in node) {
|
|
42
|
+
// Simple text: <t>value</t>
|
|
43
|
+
const tChildren = getChildren(node, 't');
|
|
44
|
+
for (const child of tChildren) {
|
|
45
|
+
if ('#text' in child) {
|
|
46
|
+
text += child['#text'] as string;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else if ('r' in node) {
|
|
50
|
+
// Rich text: <r><t>value</t></r>
|
|
51
|
+
const rChildren = getChildren(node, 'r');
|
|
52
|
+
for (const rChild of rChildren) {
|
|
53
|
+
if ('t' in rChild) {
|
|
54
|
+
const tChildren = getChildren(rChild, 't');
|
|
55
|
+
for (const child of tChildren) {
|
|
56
|
+
if ('#text' in child) {
|
|
57
|
+
text += child['#text'] as string;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get a string by index
|
|
69
|
+
*/
|
|
70
|
+
getString(index: number): string | undefined {
|
|
71
|
+
return this.strings[index];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add a string and return its index
|
|
76
|
+
* If the string already exists, returns the existing index
|
|
77
|
+
*/
|
|
78
|
+
addString(str: string): number {
|
|
79
|
+
const existing = this.stringToIndex.get(str);
|
|
80
|
+
if (existing !== undefined) {
|
|
81
|
+
return existing;
|
|
82
|
+
}
|
|
83
|
+
const index = this.strings.length;
|
|
84
|
+
this.strings.push(str);
|
|
85
|
+
this.stringToIndex.set(str, index);
|
|
86
|
+
this._dirty = true;
|
|
87
|
+
return index;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if the shared strings table has been modified
|
|
92
|
+
*/
|
|
93
|
+
get dirty(): boolean {
|
|
94
|
+
return this._dirty;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the count of strings
|
|
99
|
+
*/
|
|
100
|
+
get count(): number {
|
|
101
|
+
return this.strings.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Generate XML for the shared strings table
|
|
106
|
+
*/
|
|
107
|
+
toXml(): string {
|
|
108
|
+
const siElements: XmlNode[] = [];
|
|
109
|
+
for (const str of this.strings) {
|
|
110
|
+
const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? { 'xml:space': 'preserve' } : {}, [
|
|
111
|
+
createText(str),
|
|
112
|
+
]);
|
|
113
|
+
const siElement = createElement('si', {}, [tElement]);
|
|
114
|
+
siElements.push(siElement);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sst = createElement(
|
|
118
|
+
'sst',
|
|
119
|
+
{
|
|
120
|
+
xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
|
121
|
+
count: String(this.strings.length),
|
|
122
|
+
uniqueCount: String(this.strings.length),
|
|
123
|
+
},
|
|
124
|
+
siElements,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sst])}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import type { CellStyle, BorderType } from './types';
|
|
2
|
+
import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a color to ARGB format (8 hex chars).
|
|
6
|
+
* Accepts: "#RGB", "#RRGGBB", "RGB", "RRGGBB", "AARRGGBB", "#AARRGGBB"
|
|
7
|
+
*/
|
|
8
|
+
const normalizeColor = (color: string): string => {
|
|
9
|
+
let c = color.replace(/^#/, '').toUpperCase();
|
|
10
|
+
|
|
11
|
+
// Handle shorthand 3-char format (e.g., "FFF" -> "FFFFFF")
|
|
12
|
+
if (c.length === 3) {
|
|
13
|
+
c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Add alpha channel if not present (6 chars -> 8 chars)
|
|
17
|
+
if (c.length === 6) {
|
|
18
|
+
c = 'FF' + c;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return c;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Manages the styles (xl/styles.xml)
|
|
26
|
+
*/
|
|
27
|
+
export class Styles {
|
|
28
|
+
private _numFmts: Map<number, string> = new Map();
|
|
29
|
+
private _fonts: StyleFont[] = [];
|
|
30
|
+
private _fills: StyleFill[] = [];
|
|
31
|
+
private _borders: StyleBorder[] = [];
|
|
32
|
+
private _cellXfs: CellXf[] = []; // Cell formats (combined style index)
|
|
33
|
+
private _xmlNodes: XmlNode[] | null = null;
|
|
34
|
+
private _dirty = false;
|
|
35
|
+
|
|
36
|
+
// Cache for style deduplication
|
|
37
|
+
private _styleCache: Map<string, number> = new Map();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse styles from XML content
|
|
41
|
+
*/
|
|
42
|
+
static parse(xml: string): Styles {
|
|
43
|
+
const styles = new Styles();
|
|
44
|
+
styles._xmlNodes = parseXml(xml);
|
|
45
|
+
|
|
46
|
+
const styleSheet = findElement(styles._xmlNodes, 'styleSheet');
|
|
47
|
+
if (!styleSheet) return styles;
|
|
48
|
+
|
|
49
|
+
const children = getChildren(styleSheet, 'styleSheet');
|
|
50
|
+
|
|
51
|
+
// Parse number formats
|
|
52
|
+
const numFmts = findElement(children, 'numFmts');
|
|
53
|
+
if (numFmts) {
|
|
54
|
+
for (const child of getChildren(numFmts, 'numFmts')) {
|
|
55
|
+
if ('numFmt' in child) {
|
|
56
|
+
const id = parseInt(getAttr(child, 'numFmtId') || '0', 10);
|
|
57
|
+
const code = getAttr(child, 'formatCode') || '';
|
|
58
|
+
styles._numFmts.set(id, code);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parse fonts
|
|
64
|
+
const fonts = findElement(children, 'fonts');
|
|
65
|
+
if (fonts) {
|
|
66
|
+
for (const child of getChildren(fonts, 'fonts')) {
|
|
67
|
+
if ('font' in child) {
|
|
68
|
+
styles._fonts.push(styles._parseFont(child));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Parse fills
|
|
74
|
+
const fills = findElement(children, 'fills');
|
|
75
|
+
if (fills) {
|
|
76
|
+
for (const child of getChildren(fills, 'fills')) {
|
|
77
|
+
if ('fill' in child) {
|
|
78
|
+
styles._fills.push(styles._parseFill(child));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Parse borders
|
|
84
|
+
const borders = findElement(children, 'borders');
|
|
85
|
+
if (borders) {
|
|
86
|
+
for (const child of getChildren(borders, 'borders')) {
|
|
87
|
+
if ('border' in child) {
|
|
88
|
+
styles._borders.push(styles._parseBorder(child));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Parse cellXfs (cell formats)
|
|
94
|
+
const cellXfs = findElement(children, 'cellXfs');
|
|
95
|
+
if (cellXfs) {
|
|
96
|
+
for (const child of getChildren(cellXfs, 'cellXfs')) {
|
|
97
|
+
if ('xf' in child) {
|
|
98
|
+
styles._cellXfs.push(styles._parseCellXf(child));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return styles;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create an empty styles object with defaults
|
|
108
|
+
*/
|
|
109
|
+
static createDefault(): Styles {
|
|
110
|
+
const styles = new Styles();
|
|
111
|
+
|
|
112
|
+
// Default font (Calibri 11)
|
|
113
|
+
styles._fonts.push({
|
|
114
|
+
bold: false,
|
|
115
|
+
italic: false,
|
|
116
|
+
underline: false,
|
|
117
|
+
strike: false,
|
|
118
|
+
size: 11,
|
|
119
|
+
name: 'Calibri',
|
|
120
|
+
color: undefined,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Default fills (none and gray125 pattern are required)
|
|
124
|
+
styles._fills.push({ type: 'none' });
|
|
125
|
+
styles._fills.push({ type: 'gray125' });
|
|
126
|
+
|
|
127
|
+
// Default border (none)
|
|
128
|
+
styles._borders.push({});
|
|
129
|
+
|
|
130
|
+
// Default cell format
|
|
131
|
+
styles._cellXfs.push({
|
|
132
|
+
fontId: 0,
|
|
133
|
+
fillId: 0,
|
|
134
|
+
borderId: 0,
|
|
135
|
+
numFmtId: 0,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return styles;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private _parseFont(node: XmlNode): StyleFont {
|
|
142
|
+
const font: StyleFont = {
|
|
143
|
+
bold: false,
|
|
144
|
+
italic: false,
|
|
145
|
+
underline: false,
|
|
146
|
+
strike: false,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const children = getChildren(node, 'font');
|
|
150
|
+
for (const child of children) {
|
|
151
|
+
if ('b' in child) font.bold = true;
|
|
152
|
+
if ('i' in child) font.italic = true;
|
|
153
|
+
if ('u' in child) font.underline = true;
|
|
154
|
+
if ('strike' in child) font.strike = true;
|
|
155
|
+
if ('sz' in child) font.size = parseFloat(getAttr(child, 'val') || '11');
|
|
156
|
+
if ('name' in child) font.name = getAttr(child, 'val');
|
|
157
|
+
if ('color' in child) {
|
|
158
|
+
font.color = getAttr(child, 'rgb') || getAttr(child, 'theme');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return font;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private _parseFill(node: XmlNode): StyleFill {
|
|
166
|
+
const fill: StyleFill = { type: 'none' };
|
|
167
|
+
const children = getChildren(node, 'fill');
|
|
168
|
+
|
|
169
|
+
for (const child of children) {
|
|
170
|
+
if ('patternFill' in child) {
|
|
171
|
+
const pattern = getAttr(child, 'patternType');
|
|
172
|
+
fill.type = pattern || 'none';
|
|
173
|
+
|
|
174
|
+
const pfChildren = getChildren(child, 'patternFill');
|
|
175
|
+
for (const pfChild of pfChildren) {
|
|
176
|
+
if ('fgColor' in pfChild) {
|
|
177
|
+
fill.fgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
|
|
178
|
+
}
|
|
179
|
+
if ('bgColor' in pfChild) {
|
|
180
|
+
fill.bgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return fill;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private _parseBorder(node: XmlNode): StyleBorder {
|
|
190
|
+
const border: StyleBorder = {};
|
|
191
|
+
const children = getChildren(node, 'border');
|
|
192
|
+
|
|
193
|
+
for (const child of children) {
|
|
194
|
+
const style = getAttr(child, 'style') as BorderType | undefined;
|
|
195
|
+
if ('left' in child && style) border.left = style;
|
|
196
|
+
if ('right' in child && style) border.right = style;
|
|
197
|
+
if ('top' in child && style) border.top = style;
|
|
198
|
+
if ('bottom' in child && style) border.bottom = style;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return border;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private _parseCellXf(node: XmlNode): CellXf {
|
|
205
|
+
return {
|
|
206
|
+
fontId: parseInt(getAttr(node, 'fontId') || '0', 10),
|
|
207
|
+
fillId: parseInt(getAttr(node, 'fillId') || '0', 10),
|
|
208
|
+
borderId: parseInt(getAttr(node, 'borderId') || '0', 10),
|
|
209
|
+
numFmtId: parseInt(getAttr(node, 'numFmtId') || '0', 10),
|
|
210
|
+
alignment: this._parseAlignment(node),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private _parseAlignment(node: XmlNode): AlignmentStyle | undefined {
|
|
215
|
+
const children = getChildren(node, 'xf');
|
|
216
|
+
const alignNode = findElement(children, 'alignment');
|
|
217
|
+
if (!alignNode) return undefined;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
horizontal: getAttr(alignNode, 'horizontal') as AlignmentStyle['horizontal'],
|
|
221
|
+
vertical: getAttr(alignNode, 'vertical') as AlignmentStyle['vertical'],
|
|
222
|
+
wrapText: getAttr(alignNode, 'wrapText') === '1',
|
|
223
|
+
textRotation: parseInt(getAttr(alignNode, 'textRotation') || '0', 10),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get a style by index
|
|
229
|
+
*/
|
|
230
|
+
getStyle(index: number): CellStyle {
|
|
231
|
+
const xf = this._cellXfs[index];
|
|
232
|
+
if (!xf) return {};
|
|
233
|
+
|
|
234
|
+
const font = this._fonts[xf.fontId];
|
|
235
|
+
const fill = this._fills[xf.fillId];
|
|
236
|
+
const border = this._borders[xf.borderId];
|
|
237
|
+
const numFmt = this._numFmts.get(xf.numFmtId);
|
|
238
|
+
|
|
239
|
+
const style: CellStyle = {};
|
|
240
|
+
|
|
241
|
+
if (font) {
|
|
242
|
+
if (font.bold) style.bold = true;
|
|
243
|
+
if (font.italic) style.italic = true;
|
|
244
|
+
if (font.underline) style.underline = true;
|
|
245
|
+
if (font.strike) style.strike = true;
|
|
246
|
+
if (font.size) style.fontSize = font.size;
|
|
247
|
+
if (font.name) style.fontName = font.name;
|
|
248
|
+
if (font.color) style.fontColor = font.color;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (fill && fill.fgColor) {
|
|
252
|
+
style.fill = fill.fgColor;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (border) {
|
|
256
|
+
if (border.top || border.bottom || border.left || border.right) {
|
|
257
|
+
style.border = {
|
|
258
|
+
top: border.top,
|
|
259
|
+
bottom: border.bottom,
|
|
260
|
+
left: border.left,
|
|
261
|
+
right: border.right,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (numFmt) {
|
|
267
|
+
style.numberFormat = numFmt;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (xf.alignment) {
|
|
271
|
+
style.alignment = {
|
|
272
|
+
horizontal: xf.alignment.horizontal,
|
|
273
|
+
vertical: xf.alignment.vertical,
|
|
274
|
+
wrapText: xf.alignment.wrapText,
|
|
275
|
+
textRotation: xf.alignment.textRotation,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return style;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Create a style and return its index
|
|
284
|
+
* Uses caching to deduplicate identical styles
|
|
285
|
+
*/
|
|
286
|
+
createStyle(style: CellStyle): number {
|
|
287
|
+
const key = JSON.stringify(style);
|
|
288
|
+
const cached = this._styleCache.get(key);
|
|
289
|
+
if (cached !== undefined) {
|
|
290
|
+
return cached;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this._dirty = true;
|
|
294
|
+
|
|
295
|
+
// Create or find font
|
|
296
|
+
const fontId = this._findOrCreateFont(style);
|
|
297
|
+
|
|
298
|
+
// Create or find fill
|
|
299
|
+
const fillId = this._findOrCreateFill(style);
|
|
300
|
+
|
|
301
|
+
// Create or find border
|
|
302
|
+
const borderId = this._findOrCreateBorder(style);
|
|
303
|
+
|
|
304
|
+
// Create or find number format
|
|
305
|
+
const numFmtId = style.numberFormat ? this._findOrCreateNumFmt(style.numberFormat) : 0;
|
|
306
|
+
|
|
307
|
+
// Create cell format
|
|
308
|
+
const xf: CellXf = {
|
|
309
|
+
fontId,
|
|
310
|
+
fillId,
|
|
311
|
+
borderId,
|
|
312
|
+
numFmtId,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (style.alignment) {
|
|
316
|
+
xf.alignment = {
|
|
317
|
+
horizontal: style.alignment.horizontal,
|
|
318
|
+
vertical: style.alignment.vertical,
|
|
319
|
+
wrapText: style.alignment.wrapText,
|
|
320
|
+
textRotation: style.alignment.textRotation,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const index = this._cellXfs.length;
|
|
325
|
+
this._cellXfs.push(xf);
|
|
326
|
+
this._styleCache.set(key, index);
|
|
327
|
+
|
|
328
|
+
return index;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private _findOrCreateFont(style: CellStyle): number {
|
|
332
|
+
const font: StyleFont = {
|
|
333
|
+
bold: style.bold || false,
|
|
334
|
+
italic: style.italic || false,
|
|
335
|
+
underline: style.underline === true || style.underline === 'single' || style.underline === 'double',
|
|
336
|
+
strike: style.strike || false,
|
|
337
|
+
size: style.fontSize,
|
|
338
|
+
name: style.fontName,
|
|
339
|
+
color: style.fontColor,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Try to find existing font
|
|
343
|
+
for (let i = 0; i < this._fonts.length; i++) {
|
|
344
|
+
const f = this._fonts[i];
|
|
345
|
+
if (
|
|
346
|
+
f.bold === font.bold &&
|
|
347
|
+
f.italic === font.italic &&
|
|
348
|
+
f.underline === font.underline &&
|
|
349
|
+
f.strike === font.strike &&
|
|
350
|
+
f.size === font.size &&
|
|
351
|
+
f.name === font.name &&
|
|
352
|
+
f.color === font.color
|
|
353
|
+
) {
|
|
354
|
+
return i;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Create new font
|
|
359
|
+
this._fonts.push(font);
|
|
360
|
+
return this._fonts.length - 1;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private _findOrCreateFill(style: CellStyle): number {
|
|
364
|
+
if (!style.fill) return 0;
|
|
365
|
+
|
|
366
|
+
// Try to find existing fill
|
|
367
|
+
for (let i = 0; i < this._fills.length; i++) {
|
|
368
|
+
const f = this._fills[i];
|
|
369
|
+
if (f.fgColor === style.fill) {
|
|
370
|
+
return i;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Create new fill
|
|
375
|
+
this._fills.push({
|
|
376
|
+
type: 'solid',
|
|
377
|
+
fgColor: style.fill,
|
|
378
|
+
});
|
|
379
|
+
return this._fills.length - 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private _findOrCreateBorder(style: CellStyle): number {
|
|
383
|
+
if (!style.border) return 0;
|
|
384
|
+
|
|
385
|
+
const border: StyleBorder = {
|
|
386
|
+
top: style.border.top,
|
|
387
|
+
bottom: style.border.bottom,
|
|
388
|
+
left: style.border.left,
|
|
389
|
+
right: style.border.right,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Try to find existing border
|
|
393
|
+
for (let i = 0; i < this._borders.length; i++) {
|
|
394
|
+
const b = this._borders[i];
|
|
395
|
+
if (b.top === border.top && b.bottom === border.bottom && b.left === border.left && b.right === border.right) {
|
|
396
|
+
return i;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Create new border
|
|
401
|
+
this._borders.push(border);
|
|
402
|
+
return this._borders.length - 1;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private _findOrCreateNumFmt(format: string): number {
|
|
406
|
+
// Check if already exists
|
|
407
|
+
for (const [id, code] of this._numFmts) {
|
|
408
|
+
if (code === format) return id;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Create new (custom formats start at 164)
|
|
412
|
+
const id = Math.max(164, ...Array.from(this._numFmts.keys())) + 1;
|
|
413
|
+
this._numFmts.set(id, format);
|
|
414
|
+
return id;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Check if styles have been modified
|
|
419
|
+
*/
|
|
420
|
+
get dirty(): boolean {
|
|
421
|
+
return this._dirty;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Generate XML for styles
|
|
426
|
+
*/
|
|
427
|
+
toXml(): string {
|
|
428
|
+
const children: XmlNode[] = [];
|
|
429
|
+
|
|
430
|
+
// Number formats
|
|
431
|
+
if (this._numFmts.size > 0) {
|
|
432
|
+
const numFmtNodes: XmlNode[] = [];
|
|
433
|
+
for (const [id, code] of this._numFmts) {
|
|
434
|
+
numFmtNodes.push(createElement('numFmt', { numFmtId: String(id), formatCode: code }, []));
|
|
435
|
+
}
|
|
436
|
+
children.push(createElement('numFmts', { count: String(numFmtNodes.length) }, numFmtNodes));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Fonts
|
|
440
|
+
const fontNodes: XmlNode[] = this._fonts.map((font) => this._buildFontNode(font));
|
|
441
|
+
children.push(createElement('fonts', { count: String(fontNodes.length) }, fontNodes));
|
|
442
|
+
|
|
443
|
+
// Fills
|
|
444
|
+
const fillNodes: XmlNode[] = this._fills.map((fill) => this._buildFillNode(fill));
|
|
445
|
+
children.push(createElement('fills', { count: String(fillNodes.length) }, fillNodes));
|
|
446
|
+
|
|
447
|
+
// Borders
|
|
448
|
+
const borderNodes: XmlNode[] = this._borders.map((border) => this._buildBorderNode(border));
|
|
449
|
+
children.push(createElement('borders', { count: String(borderNodes.length) }, borderNodes));
|
|
450
|
+
|
|
451
|
+
// Cell style xfs (required but we just add a default)
|
|
452
|
+
children.push(
|
|
453
|
+
createElement('cellStyleXfs', { count: '1' }, [
|
|
454
|
+
createElement('xf', { numFmtId: '0', fontId: '0', fillId: '0', borderId: '0' }, []),
|
|
455
|
+
]),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// Cell xfs
|
|
459
|
+
const xfNodes: XmlNode[] = this._cellXfs.map((xf) => this._buildXfNode(xf));
|
|
460
|
+
children.push(createElement('cellXfs', { count: String(xfNodes.length) }, xfNodes));
|
|
461
|
+
|
|
462
|
+
// Cell styles (required)
|
|
463
|
+
children.push(
|
|
464
|
+
createElement('cellStyles', { count: '1' }, [
|
|
465
|
+
createElement('cellStyle', { name: 'Normal', xfId: '0', builtinId: '0' }, []),
|
|
466
|
+
]),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const styleSheet = createElement(
|
|
470
|
+
'styleSheet',
|
|
471
|
+
{ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' },
|
|
472
|
+
children,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([styleSheet])}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private _buildFontNode(font: StyleFont): XmlNode {
|
|
479
|
+
const children: XmlNode[] = [];
|
|
480
|
+
if (font.bold) children.push(createElement('b', {}, []));
|
|
481
|
+
if (font.italic) children.push(createElement('i', {}, []));
|
|
482
|
+
if (font.underline) children.push(createElement('u', {}, []));
|
|
483
|
+
if (font.strike) children.push(createElement('strike', {}, []));
|
|
484
|
+
if (font.size) children.push(createElement('sz', { val: String(font.size) }, []));
|
|
485
|
+
if (font.color) children.push(createElement('color', { rgb: normalizeColor(font.color) }, []));
|
|
486
|
+
if (font.name) children.push(createElement('name', { val: font.name }, []));
|
|
487
|
+
return createElement('font', {}, children);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private _buildFillNode(fill: StyleFill): XmlNode {
|
|
491
|
+
const patternChildren: XmlNode[] = [];
|
|
492
|
+
if (fill.fgColor) {
|
|
493
|
+
const rgb = normalizeColor(fill.fgColor);
|
|
494
|
+
patternChildren.push(createElement('fgColor', { rgb }, []));
|
|
495
|
+
// For solid fills, bgColor is required (indexed 64 = system background)
|
|
496
|
+
if (fill.type === 'solid') {
|
|
497
|
+
patternChildren.push(createElement('bgColor', { indexed: '64' }, []));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (fill.bgColor && fill.type !== 'solid') {
|
|
501
|
+
const rgb = normalizeColor(fill.bgColor);
|
|
502
|
+
patternChildren.push(createElement('bgColor', { rgb }, []));
|
|
503
|
+
}
|
|
504
|
+
const patternFill = createElement('patternFill', { patternType: fill.type || 'none' }, patternChildren);
|
|
505
|
+
return createElement('fill', {}, [patternFill]);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private _buildBorderNode(border: StyleBorder): XmlNode {
|
|
509
|
+
const children: XmlNode[] = [];
|
|
510
|
+
if (border.left) children.push(createElement('left', { style: border.left }, []));
|
|
511
|
+
if (border.right) children.push(createElement('right', { style: border.right }, []));
|
|
512
|
+
if (border.top) children.push(createElement('top', { style: border.top }, []));
|
|
513
|
+
if (border.bottom) children.push(createElement('bottom', { style: border.bottom }, []));
|
|
514
|
+
// Add empty elements if not present (required by Excel)
|
|
515
|
+
if (!border.left) children.push(createElement('left', {}, []));
|
|
516
|
+
if (!border.right) children.push(createElement('right', {}, []));
|
|
517
|
+
if (!border.top) children.push(createElement('top', {}, []));
|
|
518
|
+
if (!border.bottom) children.push(createElement('bottom', {}, []));
|
|
519
|
+
children.push(createElement('diagonal', {}, []));
|
|
520
|
+
return createElement('border', {}, children);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private _buildXfNode(xf: CellXf): XmlNode {
|
|
524
|
+
const attrs: Record<string, string> = {
|
|
525
|
+
numFmtId: String(xf.numFmtId),
|
|
526
|
+
fontId: String(xf.fontId),
|
|
527
|
+
fillId: String(xf.fillId),
|
|
528
|
+
borderId: String(xf.borderId),
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
if (xf.fontId > 0) attrs.applyFont = '1';
|
|
532
|
+
if (xf.fillId > 0) attrs.applyFill = '1';
|
|
533
|
+
if (xf.borderId > 0) attrs.applyBorder = '1';
|
|
534
|
+
if (xf.numFmtId > 0) attrs.applyNumberFormat = '1';
|
|
535
|
+
|
|
536
|
+
const children: XmlNode[] = [];
|
|
537
|
+
if (xf.alignment) {
|
|
538
|
+
const alignAttrs: Record<string, string> = {};
|
|
539
|
+
if (xf.alignment.horizontal) alignAttrs.horizontal = xf.alignment.horizontal;
|
|
540
|
+
if (xf.alignment.vertical) alignAttrs.vertical = xf.alignment.vertical;
|
|
541
|
+
if (xf.alignment.wrapText) alignAttrs.wrapText = '1';
|
|
542
|
+
if (xf.alignment.textRotation) alignAttrs.textRotation = String(xf.alignment.textRotation);
|
|
543
|
+
children.push(createElement('alignment', alignAttrs, []));
|
|
544
|
+
attrs.applyAlignment = '1';
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return createElement('xf', attrs, children);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Internal types for style components
|
|
552
|
+
interface StyleFont {
|
|
553
|
+
bold: boolean;
|
|
554
|
+
italic: boolean;
|
|
555
|
+
underline: boolean;
|
|
556
|
+
strike: boolean;
|
|
557
|
+
size?: number;
|
|
558
|
+
name?: string;
|
|
559
|
+
color?: string;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
interface StyleFill {
|
|
563
|
+
type: string;
|
|
564
|
+
fgColor?: string;
|
|
565
|
+
bgColor?: string;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
interface StyleBorder {
|
|
569
|
+
top?: BorderType;
|
|
570
|
+
bottom?: BorderType;
|
|
571
|
+
left?: BorderType;
|
|
572
|
+
right?: BorderType;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
interface CellXf {
|
|
576
|
+
fontId: number;
|
|
577
|
+
fillId: number;
|
|
578
|
+
borderId: number;
|
|
579
|
+
numFmtId: number;
|
|
580
|
+
alignment?: AlignmentStyle;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
interface AlignmentStyle {
|
|
584
|
+
horizontal?: 'left' | 'center' | 'right' | 'justify';
|
|
585
|
+
vertical?: 'top' | 'middle' | 'bottom';
|
|
586
|
+
wrapText?: boolean;
|
|
587
|
+
textRotation?: number;
|
|
588
|
+
}
|