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