@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,1549 @@
1
+ // =============================================================================
2
+ // .xlsx Export Writer
3
+ // =============================================================================
4
+ //
5
+ // Serializes WorkbookModel → OOXML XML → ZIP (.xlsx).
6
+ // Strategy: preserve unmodified parts from the original ZIP,
7
+ // re-serialize only changed sheets/styles/shared strings.
8
+ // =============================================================================
9
+
10
+ import { zipSync, strToU8 } from "fflate";
11
+ import type { WorkbookModel, SheetModel, CellModel } from "./workbook_model";
12
+ import type { CellStyle, RichTextPart, RichTextFont } from "./types";
13
+ import type { ParsedChart, ParsedTable, ParsedDrawing, ChartType, ChartSeries } from "./ooxml_chart_types";
14
+ import type { ParsedConditionalFormat, ParsedCfStyle } from "./excel_cf_types";
15
+ import { rowColToRef, refToRowCol, colNumToLetters } from "./excel_utils";
16
+ import { hexToArgb, hexToOoxmlRgb } from "./ooxml_color";
17
+
18
+ // =============================================================================
19
+ // Pivot round-trip helpers
20
+ // =============================================================================
21
+ //
22
+ // The writer regenerates workbook.xml, workbook.xml.rels, [Content_Types].xml,
23
+ // and every per-sheet rels file. Pivot tables and caches live in their own
24
+ // xml files (xl/pivotTables/*.xml, xl/pivotCache/*.xml) which the writer
25
+ // passes through verbatim from `originalZip`. But we lose the cross-references
26
+ // unless we reconstitute them here.
27
+ //
28
+ // `extractPivotRoundTripInfo` reads the *original* workbook + sheet rels from
29
+ // the loaded ZIP and returns a structured snapshot the regenerated parts can
30
+ // consult.
31
+ // =============================================================================
32
+
33
+ interface PivotRoundTripInfo {
34
+ /** cacheId → workbook-relative cache-definition path (e.g. "pivotCache/pivotCacheDefinition1.xml"). */
35
+ cachesByWorkbook: Map<number, string>;
36
+ /** Sheet index (0-based, in workbook order) → pivot table file paths
37
+ * relative to the sheet (e.g. "../pivotTables/pivotTable1.xml"). */
38
+ pivotTablesBySheetIndex: Map<number, string[]>;
39
+ /** All pivot xml file paths (workbook-absolute) discovered in the original
40
+ * zip. Used to add Content-Types overrides for each. */
41
+ pivotXmlPaths: string[];
42
+ }
43
+
44
+ function readWorkbookSheetOrder(originalZip: Record<string, Uint8Array>): string[] {
45
+ const wbBytes = originalZip["xl/workbook.xml"];
46
+ if (!wbBytes) return [];
47
+ const xml = decode(wbBytes);
48
+ // Match every <sheet ... r:id="rIdN"/> in document order. Workbook always
49
+ // emits sheets in the same order they appear here, so the index → rId map
50
+ // lets us follow the workbook rel target back to the sheet xml path.
51
+ const out: string[] = [];
52
+ const re = /<sheet\b[^>]*?r:id="([^"]+)"/g;
53
+ let m: RegExpExecArray | null;
54
+ while ((m = re.exec(xml)) !== null) {
55
+ out.push(m[1]);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function readWorkbookRels(
61
+ originalZip: Record<string, Uint8Array>,
62
+ ): Map<string, { type: string; target: string }> {
63
+ const out = new Map<string, { type: string; target: string }>();
64
+ const bytes = originalZip["xl/_rels/workbook.xml.rels"];
65
+ if (!bytes) return out;
66
+ const xml = decode(bytes);
67
+ const re = /<Relationship\b([^/]*?)\/>/g;
68
+ let m: RegExpExecArray | null;
69
+ while ((m = re.exec(xml)) !== null) {
70
+ const attrs = m[1];
71
+ const id = /\bId="([^"]+)"/.exec(attrs)?.[1];
72
+ const type = /\bType="([^"]+)"/.exec(attrs)?.[1] ?? "";
73
+ const target = /\bTarget="([^"]+)"/.exec(attrs)?.[1] ?? "";
74
+ if (id) out.set(id, { type, target });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function readPivotCacheIdMap(
80
+ originalZip: Record<string, Uint8Array>,
81
+ ): Map<number, string> {
82
+ // Returns cacheId → r:id from original workbook.xml's <pivotCaches>.
83
+ const out = new Map<number, string>();
84
+ const bytes = originalZip["xl/workbook.xml"];
85
+ if (!bytes) return out;
86
+ const xml = decode(bytes);
87
+ const re = /<pivotCache\b([^/]*?)\/>/g;
88
+ let m: RegExpExecArray | null;
89
+ while ((m = re.exec(xml)) !== null) {
90
+ const attrs = m[1];
91
+ const cacheIdMatch = /\bcacheId="(\d+)"/.exec(attrs);
92
+ const ridMatch = /\br:id="([^"]+)"/.exec(attrs);
93
+ if (cacheIdMatch && ridMatch) {
94
+ out.set(parseInt(cacheIdMatch[1], 10), ridMatch[1]);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function readSheetPivotPaths(
101
+ originalZip: Record<string, Uint8Array>,
102
+ sheetTarget: string,
103
+ ): string[] {
104
+ // sheetTarget is a workbook-rel path like "worksheets/sheet1.xml". The rels
105
+ // file lives next to it under _rels/. Extract pivot-table targets.
106
+ const sheetPath = `xl/${sheetTarget}`;
107
+ const relsPath = sheetPath.replace(/([^/]+)$/, "_rels/$1.rels");
108
+ const bytes = originalZip[relsPath];
109
+ if (!bytes) return [];
110
+ const xml = decode(bytes);
111
+ const re = /<Relationship\b([^/]*?)\/>/g;
112
+ const out: string[] = [];
113
+ let m: RegExpExecArray | null;
114
+ while ((m = re.exec(xml)) !== null) {
115
+ const attrs = m[1];
116
+ const type = /\bType="([^"]+)"/.exec(attrs)?.[1] ?? "";
117
+ if (!type.includes("/pivotTable")) continue;
118
+ const target = /\bTarget="([^"]+)"/.exec(attrs)?.[1];
119
+ if (target) out.push(target);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function extractPivotRoundTripInfo(
125
+ originalZip: Record<string, Uint8Array> | undefined,
126
+ ): PivotRoundTripInfo {
127
+ const empty: PivotRoundTripInfo = {
128
+ cachesByWorkbook: new Map(),
129
+ pivotTablesBySheetIndex: new Map(),
130
+ pivotXmlPaths: [],
131
+ };
132
+ if (!originalZip) return empty;
133
+
134
+ const sheetRIds = readWorkbookSheetOrder(originalZip);
135
+ const wbRels = readWorkbookRels(originalZip);
136
+ const cacheRIds = readPivotCacheIdMap(originalZip);
137
+
138
+ // Cache id → cache xml path
139
+ const cachesByWorkbook = new Map<number, string>();
140
+ for (const [cacheId, rid] of cacheRIds) {
141
+ const rel = wbRels.get(rid);
142
+ if (!rel) continue;
143
+ cachesByWorkbook.set(cacheId, rel.target);
144
+ }
145
+
146
+ // Per-sheet pivot table file paths
147
+ const pivotTablesBySheetIndex = new Map<number, string[]>();
148
+ for (let i = 0; i < sheetRIds.length; i++) {
149
+ const sheetTarget = wbRels.get(sheetRIds[i])?.target;
150
+ if (!sheetTarget) continue;
151
+ const paths = readSheetPivotPaths(originalZip, sheetTarget);
152
+ if (paths.length > 0) pivotTablesBySheetIndex.set(i, paths);
153
+ }
154
+
155
+ // All pivot xml files in the zip (used for content_types overrides)
156
+ const pivotXmlPaths: string[] = [];
157
+ for (const path of Object.keys(originalZip)) {
158
+ if (path.startsWith("xl/pivotTables/") && path.endsWith(".xml")) {
159
+ pivotXmlPaths.push(path);
160
+ }
161
+ if (path.startsWith("xl/pivotCache/") && path.endsWith(".xml")) {
162
+ pivotXmlPaths.push(path);
163
+ }
164
+ }
165
+
166
+ return { cachesByWorkbook, pivotTablesBySheetIndex, pivotXmlPaths };
167
+ }
168
+
169
+ function decode(bytes: Uint8Array): string {
170
+ return new TextDecoder().decode(bytes);
171
+ }
172
+
173
+ function pivotContentTypeFor(path: string): string | undefined {
174
+ if (path.startsWith("xl/pivotTables/") && path.endsWith(".xml")) {
175
+ return `<Override PartName="/${path}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"/>`;
176
+ }
177
+ if (path.includes("/pivotCacheDefinition") && path.endsWith(".xml")) {
178
+ return `<Override PartName="/${path}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"/>`;
179
+ }
180
+ if (path.includes("/pivotCacheRecords") && path.endsWith(".xml")) {
181
+ return `<Override PartName="/${path}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"/>`;
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ // =============================================================================
187
+ // Main export function
188
+ // =============================================================================
189
+
190
+ /**
191
+ * Export a workbook to .xlsx format.
192
+ * If originalZip is provided, unmodified entries are preserved.
193
+ */
194
+ export function exportWorkbook(
195
+ workbook: WorkbookModel,
196
+ originalZip?: Record<string, Uint8Array>,
197
+ ): Uint8Array {
198
+ const entries: Record<string, Uint8Array> = {};
199
+
200
+ // Styles passthrough mode: when the in-memory model has no style mutations
201
+ // (no new styles interned, no new numFmtCodes registered) AND we have an
202
+ // original styles.xml to keep, the writer skips rebuilding the styles part
203
+ // entirely. Cells then write their original `s=` xf indexes (captured at
204
+ // load time as `cell.originalXfIndex`). This is the dominant fast path for
205
+ // value-only template fills (e.g. `generate_excel_from_template`).
206
+ const stylesPassthrough = !!originalZip && !workbook.styles.dirty && !!originalZip["xl/styles.xml"];
207
+
208
+ // Pass through unmodified files from original ZIP, excluding paths
209
+ // that will be regenerated (prevents stale/orphan entries)
210
+ if (originalZip) {
211
+ const regeneratedPaths = new Set<string>();
212
+ regeneratedPaths.add("xl/sharedStrings.xml");
213
+ if (!stylesPassthrough) regeneratedPaths.add("xl/styles.xml");
214
+ regeneratedPaths.add("xl/workbook.xml");
215
+ regeneratedPaths.add("xl/_rels/workbook.xml.rels");
216
+ regeneratedPaths.add("[Content_Types].xml");
217
+ regeneratedPaths.add("_rels/.rels");
218
+ regeneratedPaths.add("docProps/core.xml");
219
+ regeneratedPaths.add("docProps/app.xml");
220
+ for (let i = 0; i < workbook.sheets.length + 10; i++) {
221
+ regeneratedPaths.add(`xl/worksheets/sheet${i + 1}.xml`);
222
+ regeneratedPaths.add(`xl/worksheets/_rels/sheet${i + 1}.xml.rels`);
223
+ }
224
+ for (const path of Object.keys(originalZip)) {
225
+ if (path.startsWith("xl/drawings/") || path.startsWith("xl/charts/") || path.startsWith("xl/tables/") || path.startsWith("xl/media/")) {
226
+ regeneratedPaths.add(path);
227
+ }
228
+ }
229
+ for (const [path, data] of Object.entries(originalZip)) {
230
+ if (!regeneratedPaths.has(path)) entries[path] = data;
231
+ }
232
+ }
233
+
234
+ // Build shared strings
235
+ const sharedStrings = buildSharedStrings(workbook);
236
+ entries["xl/sharedStrings.xml"] = strToU8(sharedStrings.xml);
237
+
238
+ // Build styles only if we're not in passthrough mode. The xfMap is still
239
+ // needed for cells that don't have an originalXfIndex (e.g. cells created
240
+ // post-load with a brand-new style — but in that case dirty would be true
241
+ // and we'd take the regen path).
242
+ const stylesResult = stylesPassthrough
243
+ ? { xml: "", xfMap: new Map<string, number>(), numFmtMap: new Map<string, number>(), dxfMap: new Map<string, number>() }
244
+ : buildStylesXml(workbook);
245
+ if (!stylesPassthrough) entries["xl/styles.xml"] = strToU8(stylesResult.xml);
246
+
247
+ // Pivot round-trip: read original workbook.xml + sheet rels to recover the
248
+ // (cacheId → cacheDefinition path) and (sheet → pivotTable paths) mappings.
249
+ // The pivot xml files themselves pass through originalZip; we only need to
250
+ // re-emit the cross-references in the regenerated parts.
251
+ const pivotInfo = extractPivotRoundTripInfo(originalZip);
252
+
253
+ // Build workbook.xml
254
+ entries["xl/workbook.xml"] = strToU8(buildWorkbookXml(workbook, pivotInfo));
255
+
256
+ // Build workbook relationships
257
+ entries["xl/_rels/workbook.xml.rels"] = strToU8(
258
+ buildWorkbookRels(workbook, pivotInfo),
259
+ );
260
+
261
+ // Track extra content types for charts, images, tables
262
+ const extraContentTypes: string[] = [];
263
+
264
+ // Pivot xml files that pass through originalZip still need a content-type
265
+ // override so Excel doesn't reject them as unknown parts.
266
+ for (const path of pivotInfo.pivotXmlPaths) {
267
+ const ct = pivotContentTypeFor(path);
268
+ if (ct) extraContentTypes.push(ct);
269
+ }
270
+ let globalChartIndex = 1;
271
+ let globalImageIndex = 1;
272
+ let globalTableIndex = 1;
273
+
274
+ // Build each sheet + per-sheet drawings, charts, tables, images
275
+ for (let i = 0; i < workbook.sheets.length; i++) {
276
+ const sheet = workbook.sheets[i];
277
+ const sheetRels: string[] = [];
278
+ let nextRId = 1;
279
+
280
+ const hasCharts = sheet.charts.length > 0;
281
+ const hasImages = sheet.images.length > 0;
282
+ const hasDrawings = sheet.drawings.length > 0;
283
+ const hasTables = sheet.tables.length > 0;
284
+ const hasHyperlinks = sheet.hyperlinks.size > 0;
285
+ const needsDrawing = hasCharts || hasImages || hasDrawings;
286
+
287
+ // Hyperlink relationships (external links need rId)
288
+ const hyperlinkRIds = new Map<string, string>();
289
+ if (hasHyperlinks) {
290
+ for (const [ref, url] of sheet.hyperlinks) {
291
+ const rId = `rId${nextRId++}`;
292
+ hyperlinkRIds.set(ref, rId);
293
+ sheetRels.push(`<Relationship Id="${rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="${escapeXml(url)}" TargetMode="External"/>`);
294
+ }
295
+ }
296
+
297
+ // Drawing relationships (charts + images + shapes share a drawing)
298
+ let drawingRId = "";
299
+ if (needsDrawing) {
300
+ drawingRId = `rId${nextRId++}`;
301
+ sheetRels.push(`<Relationship Id="${drawingRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing${i + 1}.xml"/>`);
302
+
303
+ const drawingRels: string[] = [];
304
+ let drawingRelId = 1;
305
+ const drawingAnchors: string[] = [];
306
+
307
+ // Charts
308
+ for (const chart of sheet.charts) {
309
+ const chartRId = `rId${drawingRelId++}`;
310
+ const chartPath = `xl/charts/chart${globalChartIndex}.xml`;
311
+ entries[chartPath] = strToU8(buildChartXml(chart));
312
+ extraContentTypes.push(`<Override PartName="/${chartPath}" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`);
313
+ drawingRels.push(`<Relationship Id="${chartRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart${globalChartIndex}.xml"/>`);
314
+ drawingAnchors.push(buildChartAnchorXml(chart, chartRId));
315
+ globalChartIndex++;
316
+ }
317
+
318
+ // Images
319
+ for (const image of sheet.images) {
320
+ const imgRId = `rId${drawingRelId++}`;
321
+ const ext = getImageExtension(image.dataUrl);
322
+ const imgPath = `xl/media/image${globalImageIndex}.${ext}`;
323
+ const imgBytes = dataUrlToBytes(image.dataUrl);
324
+ if (imgBytes) {
325
+ entries[imgPath] = imgBytes;
326
+ drawingRels.push(`<Relationship Id="${imgRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image${globalImageIndex}.${ext}"/>`);
327
+ drawingAnchors.push(buildImageAnchorXml(image, imgRId));
328
+ globalImageIndex++;
329
+ }
330
+ }
331
+
332
+ // Shapes / textboxes / connectors
333
+ for (const drawing of sheet.drawings) {
334
+ drawingAnchors.push(buildShapeAnchorXml(drawing));
335
+ }
336
+
337
+ // Write drawing XML
338
+ entries[`xl/drawings/drawing${i + 1}.xml`] = strToU8(
339
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
340
+ `<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">\n` +
341
+ drawingAnchors.join("\n") +
342
+ `\n</xdr:wsDr>`,
343
+ );
344
+ extraContentTypes.push(`<Override PartName="/xl/drawings/drawing${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>`);
345
+
346
+ // Drawing rels
347
+ if (drawingRels.length > 0) {
348
+ entries[`xl/drawings/_rels/drawing${i + 1}.xml.rels`] = strToU8(
349
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n${drawingRels.join("\n")}\n</Relationships>`,
350
+ );
351
+ }
352
+ }
353
+
354
+ // Tables
355
+ const tableRIds: string[] = [];
356
+ if (hasTables) {
357
+ for (const table of sheet.tables) {
358
+ const tableRId = `rId${nextRId++}`;
359
+ tableRIds.push(tableRId);
360
+ const tablePath = `xl/tables/table${globalTableIndex}.xml`;
361
+ entries[tablePath] = strToU8(buildTableXml(table, globalTableIndex));
362
+ extraContentTypes.push(`<Override PartName="/${tablePath}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>`);
363
+ sheetRels.push(`<Relationship Id="${tableRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table${globalTableIndex}.xml"/>`);
364
+ globalTableIndex++;
365
+ }
366
+ }
367
+
368
+ // Pivot tables: re-emit relationships pointing to the original pivot xml
369
+ // files (which pass through verbatim via originalZip).
370
+ const pivotTargets = pivotInfo.pivotTablesBySheetIndex.get(i);
371
+ if (pivotTargets && pivotTargets.length > 0) {
372
+ for (const target of pivotTargets) {
373
+ const rId = `rId${nextRId++}`;
374
+ sheetRels.push(
375
+ `<Relationship Id="${rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable" Target="${escapeXml(target)}"/>`,
376
+ );
377
+ }
378
+ }
379
+
380
+ // Write sheet rels if any
381
+ if (sheetRels.length > 0) {
382
+ entries[`xl/worksheets/_rels/sheet${i + 1}.xml.rels`] = strToU8(
383
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n${sheetRels.join("\n")}\n</Relationships>`,
384
+ );
385
+ }
386
+
387
+ // Build sheet XML
388
+ entries[`xl/worksheets/sheet${i + 1}.xml`] = strToU8(
389
+ buildSheetXml(sheet, workbook.styles, sharedStrings.index, i === workbook.activeSheetIndex, stylesResult.xfMap, stylesResult.numFmtMap, stylesResult.dxfMap, drawingRId, tableRIds, hyperlinkRIds),
390
+ );
391
+ }
392
+
393
+ // Document properties (required by Excel for full functionality)
394
+ entries["docProps/core.xml"] = strToU8(buildCoreProps());
395
+ entries["docProps/app.xml"] = strToU8(buildAppProps());
396
+
397
+ // Content Types
398
+ entries["[Content_Types].xml"] = strToU8(buildContentTypes(workbook.sheets.length, extraContentTypes));
399
+
400
+ // Root rels
401
+ entries["_rels/.rels"] = strToU8(buildRootRels());
402
+
403
+ return zipSync(entries, { level: 6 });
404
+ }
405
+
406
+ // =============================================================================
407
+ // Shared strings
408
+ // =============================================================================
409
+
410
+ interface SharedStringsResult {
411
+ xml: string;
412
+ index: Map<string, number>;
413
+ }
414
+
415
+ function buildSharedStrings(workbook: WorkbookModel): SharedStringsResult {
416
+ const strings: string[] = [];
417
+ const index = new Map<string, number>();
418
+ // Track rich text parts for each unique string (first one wins)
419
+ const richTextMap = new Map<string, RichTextPart[]>();
420
+ let totalCount = 0;
421
+
422
+ for (const sheet of workbook.sheets) {
423
+ for (const cell of sheet.cells.values()) {
424
+ // Skip error cells and formula cells with string cached values — they use inline types
425
+ if (cell.error) continue;
426
+ if (cell.formula && typeof cell.value === "string") continue;
427
+
428
+ if (typeof cell.value === "string") {
429
+ totalCount++;
430
+ if (!index.has(cell.value)) {
431
+ index.set(cell.value, strings.length);
432
+ strings.push(cell.value);
433
+ if (cell.richText && cell.richText.length > 0) {
434
+ richTextMap.set(cell.value, cell.richText);
435
+ }
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ const siEntries = strings.map((s) => {
442
+ const richText = richTextMap.get(s);
443
+ if (richText) {
444
+ return `<si>${richText.map((part) => buildRichTextRun(part)).join("")}</si>`;
445
+ }
446
+ const needsPreserve = s.length === 0 || s !== s.trim();
447
+ const spaceAttr = needsPreserve ? ' xml:space="preserve"' : "";
448
+ return `<si><t${spaceAttr}>${escapeXml(s)}</t></si>`;
449
+ });
450
+
451
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
452
+ <sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${totalCount}" uniqueCount="${strings.length}">
453
+ ${siEntries.join("\n")}
454
+ </sst>`;
455
+
456
+ return { xml, index };
457
+ }
458
+
459
+ /** Build a single <r> element for a rich text run. */
460
+ function buildRichTextRun(part: RichTextPart): string {
461
+ const needsPreserve = part.text.length === 0 || part.text !== part.text.trim();
462
+ const spaceAttr = needsPreserve ? ' xml:space="preserve"' : "";
463
+ if (!part.font) {
464
+ return `<r><t${spaceAttr}>${escapeXml(part.text)}</t></r>`;
465
+ }
466
+ return `<r>${buildRichTextRunProps(part.font)}<t${spaceAttr}>${escapeXml(part.text)}</t></r>`;
467
+ }
468
+
469
+ /** Build <rPr> element from RichTextFont. */
470
+ function buildRichTextRunProps(font: RichTextFont): string {
471
+ let parts = "";
472
+ if (font.bold) parts += "<b/>";
473
+ if (font.italic) parts += "<i/>";
474
+ if (font.strike) parts += "<strike/>";
475
+ if (font.underline) parts += `<u val="${font.underline}"/>`;
476
+ if (font.vertAlign) parts += `<vertAlign val="${font.vertAlign}"/>`;
477
+ if (font.size) parts += `<sz val="${font.size}"/>`;
478
+ if (font.color) parts += `<color rgb="${hexToArgb(font.color)}"/>`;
479
+ if (font.name) parts += `<rFont val="${escapeXml(font.name)}"/>`;
480
+ return `<rPr>${parts}</rPr>`;
481
+ }
482
+
483
+ // =============================================================================
484
+ // Styles
485
+ // =============================================================================
486
+
487
+ interface StylesResult {
488
+ xml: string;
489
+ xfMap: Map<string, number>; // "styleIdx:numFmtId" → xf index
490
+ numFmtMap: Map<string, number>; // numFmtCode → numFmtId
491
+ dxfMap: Map<string, number>; // serialized ParsedCfStyle → dxf index
492
+ }
493
+
494
+ function buildStylesXml(workbook: WorkbookModel): StylesResult {
495
+ // Collect all unique styles
496
+ const styles: CellStyle[] = [];
497
+ for (let i = 0; i < workbook.styles.size; i++) {
498
+ styles.push(workbook.styles.get(i));
499
+ }
500
+
501
+ // Collect unique number formats from cells
502
+ const numFmtMap = new Map<string, number>(); // numFmtCode → numFmtId
503
+ let nextNumFmtId = 164; // Excel reserves 0-163 for built-in formats
504
+ for (const sheet of workbook.sheets) {
505
+ for (const cell of sheet.cells.values()) {
506
+ if (cell.numFmtCode && cell.numFmtCode !== "General" && cell.numFmtCode !== "") {
507
+ if (!numFmtMap.has(cell.numFmtCode)) {
508
+ numFmtMap.set(cell.numFmtCode, nextNumFmtId++);
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ // Build per-cell numFmtId lookup: "sheetIdx:ref" → numFmtId
515
+ const cellNumFmtIds = new Map<string, number>();
516
+ for (let si = 0; si < workbook.sheets.length; si++) {
517
+ for (const [ref, cell] of workbook.sheets[si].cells) {
518
+ if (cell.numFmtCode && cell.numFmtCode !== "General" && cell.numFmtCode !== "") {
519
+ const id = numFmtMap.get(cell.numFmtCode);
520
+ if (id !== undefined) cellNumFmtIds.set(`${si}:${ref}`, id);
521
+ }
522
+ }
523
+ }
524
+
525
+ // Collect unique fonts
526
+ const fonts = new Map<string, number>();
527
+ const fontList: CellStyle[] = [];
528
+ fonts.set("default", 0);
529
+ fontList.push({});
530
+
531
+ for (const s of styles) {
532
+ const key = fontKey(s);
533
+ if (!fonts.has(key)) {
534
+ fonts.set(key, fontList.length);
535
+ fontList.push(s);
536
+ }
537
+ }
538
+
539
+ // Collect unique fills — each fill is keyed by a composite string to deduplicate
540
+ const fillEntries: string[] = []; // XML strings for each fill
541
+ const fillMap = new Map<string, number>(); // fillKey → fillId
542
+
543
+ // Excel requires exactly these 2 default fills at indices 0 and 1
544
+ fillEntries.push('<fill><patternFill patternType="none"/></fill>');
545
+ fillEntries.push('<fill><patternFill patternType="gray125"/></fill>');
546
+ fillMap.set("", 0); // empty/no fill → index 0
547
+
548
+ for (const s of styles) {
549
+ const fk = fillKey(s);
550
+ if (fk === "" || fillMap.has(fk)) continue;
551
+ fillMap.set(fk, fillEntries.length);
552
+ fillEntries.push(buildFillXml(s));
553
+ }
554
+
555
+ // Collect unique borders
556
+ const borderEntries: string[] = [];
557
+ const borderMap = new Map<string, number>();
558
+
559
+ // Default empty border at index 0
560
+ borderEntries.push("<border><left/><right/><top/><bottom/><diagonal/></border>");
561
+ borderMap.set("", 0);
562
+
563
+ for (const s of styles) {
564
+ const bk = borderKey(s);
565
+ if (bk === "" || borderMap.has(bk)) continue;
566
+ borderMap.set(bk, borderEntries.length);
567
+ borderEntries.push(buildBorderXml(s));
568
+ }
569
+
570
+ const fontsXml = fontList.map((f) => buildFontXml(f)).join("\n");
571
+ const fillsXml = fillEntries.join("\n");
572
+ const bordersXml = borderEntries.join("\n");
573
+
574
+ // Build numFmts XML
575
+ let numFmtsXml = "";
576
+ if (numFmtMap.size > 0) {
577
+ const entries = Array.from(numFmtMap.entries())
578
+ .map(([code, id]) => `<numFmt numFmtId="${id}" formatCode="${escapeXml(code)}"/>`)
579
+ .join("\n");
580
+ numFmtsXml = `<numFmts count="${numFmtMap.size}">\n${entries}\n</numFmts>\n`;
581
+ }
582
+
583
+ // Build a mapping from styleIndex to the set of numFmtIds used with that style.
584
+ // Since numFmtCode lives on CellModel, not CellStyle, we need to create separate
585
+ // xf entries for each (styleIndex, numFmtId) combination.
586
+ const xfEntries: string[] = [];
587
+ const xfMap = new Map<string, number>(); // "styleIdx:numFmtId" → xf index
588
+
589
+ for (let styleIdx = 0; styleIdx < styles.length; styleIdx++) {
590
+ const s = styles[styleIdx];
591
+ const fontId = fonts.get(fontKey(s)) ?? 0;
592
+ const fillId = fillMap.get(fillKey(s)) ?? 0;
593
+ const borderId = borderMap.get(borderKey(s)) ?? 0;
594
+
595
+ // Default entry: styleIdx with numFmtId=0
596
+ const xfKey = `${styleIdx}:0`;
597
+ xfMap.set(xfKey, xfEntries.length);
598
+ xfEntries.push(buildXfXml(s, fontId, fillId, borderId, 0));
599
+ }
600
+
601
+ // Add additional xf entries for cells that have custom numFmtIds
602
+ for (const [cellKey, numFmtId] of cellNumFmtIds) {
603
+ const [siStr, ref] = cellKey.split(":");
604
+ const cell = workbook.sheets[parseInt(siStr)].cells.get(ref);
605
+ if (!cell) continue;
606
+ const xfKey = `${cell.styleIndex}:${numFmtId}`;
607
+ if (xfMap.has(xfKey)) continue;
608
+ const s = styles[cell.styleIndex] ?? {};
609
+ const fontId = fonts.get(fontKey(s)) ?? 0;
610
+ const fillId = fillMap.get(fillKey(s)) ?? 0;
611
+ const borderId = borderMap.get(borderKey(s)) ?? 0;
612
+ xfMap.set(xfKey, xfEntries.length);
613
+ xfEntries.push(buildXfXml(s, fontId, fillId, borderId, numFmtId));
614
+ }
615
+
616
+ // Collect differential formatting (dxf) entries for style-based conditional formatting
617
+ const dxfEntries: string[] = [];
618
+ const dxfMap = new Map<string, number>(); // serialized style → dxf index
619
+
620
+ for (const sheet of workbook.sheets) {
621
+ for (const cf of sheet.conditionalFormats) {
622
+ for (const rule of cf.rules) {
623
+ if (rule.ruleType === "style" && rule.style) {
624
+ const key = JSON.stringify(rule.style);
625
+ if (!dxfMap.has(key)) {
626
+ dxfMap.set(key, dxfEntries.length);
627
+ dxfEntries.push(buildDxfXml(rule.style));
628
+ }
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ let dxfsXml = '<dxfs count="0"/>';
635
+ if (dxfEntries.length > 0) {
636
+ dxfsXml = `<dxfs count="${dxfEntries.length}">\n${dxfEntries.join("\n")}\n</dxfs>`;
637
+ }
638
+
639
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
640
+ <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
641
+ ${numFmtsXml}<fonts count="${fontList.length}">
642
+ ${fontsXml}
643
+ </fonts>
644
+ <fills count="${fillEntries.length}">
645
+ ${fillsXml}
646
+ </fills>
647
+ <borders count="${borderEntries.length}">
648
+ ${bordersXml}
649
+ </borders>
650
+ <cellStyleXfs count="1">
651
+ <xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>
652
+ </cellStyleXfs>
653
+ <cellXfs count="${xfEntries.length}">
654
+ ${xfEntries.join("\n")}
655
+ </cellXfs>
656
+ <cellStyles count="1">
657
+ <cellStyle name="Normal" xfId="0" builtinId="0"/>
658
+ </cellStyles>
659
+ ${dxfsXml}
660
+ </styleSheet>`;
661
+
662
+ return { xml, xfMap, numFmtMap, dxfMap };
663
+ }
664
+
665
+ /** Build a single <xf> element for cellXfs (references cellStyleXfs via xfId). */
666
+ function buildXfXml(s: CellStyle, fontId: number, fillId: number, borderId: number, numFmtId: number): string {
667
+ let attrs = `numFmtId="${numFmtId}" fontId="${fontId}" fillId="${fillId}" borderId="${borderId}" xfId="0"`;
668
+ if (numFmtId > 0) attrs += ' applyNumberFormat="1"';
669
+ if (fontId > 0) attrs += ' applyFont="1"';
670
+ if (fillId > 0) attrs += ' applyFill="1"';
671
+ if (borderId > 0) attrs += ' applyBorder="1"';
672
+ if (s.horizontalAlign || s.verticalAlign || s.wrapText || s.indent || s.textRotation || s.shrinkToFit) {
673
+ const hAlign = s.horizontalAlign ? ` horizontal="${s.horizontalAlign}"` : "";
674
+ const vAlign = s.verticalAlign ? ` vertical="${s.verticalAlign}"` : "";
675
+ const wrap = s.wrapText ? ' wrapText="1"' : "";
676
+ const indent = s.indent ? ` indent="${s.indent}"` : "";
677
+ const rotation = s.textRotation !== undefined ? ` textRotation="${s.textRotation === "vertical" ? 255 : s.textRotation}"` : "";
678
+ const shrink = s.shrinkToFit ? ' shrinkToFit="1"' : "";
679
+ return `<xf ${attrs} applyAlignment="1"><alignment${hAlign}${vAlign}${wrap}${indent}${rotation}${shrink}/></xf>`;
680
+ }
681
+ return `<xf ${attrs}/>`;
682
+ }
683
+
684
+ function fontKey(s: CellStyle): string {
685
+ return `${s.fontName ?? ""}|${s.fontSize ?? 0}|${s.fontBold ? 1 : 0}|${s.fontItalic ? 1 : 0}|${s.fontColor ?? ""}|${s.fontUnderline ?? ""}|${s.fontStrike ? 1 : 0}`;
686
+ }
687
+
688
+ function buildFontXml(s: CellStyle): string {
689
+ let parts = "";
690
+ if (s.fontBold) parts += "<b/>";
691
+ if (s.fontItalic) parts += "<i/>";
692
+ if (s.fontStrike) parts += "<strike/>";
693
+ if (s.fontUnderline) parts += `<u val="${s.fontUnderline}"/>`;
694
+ parts += `<sz val="${s.fontSize ?? 11}"/>`;
695
+ if (s.fontColor) {
696
+ parts += `<color rgb="${hexToArgb(s.fontColor)}"/>`;
697
+ } else {
698
+ parts += '<color theme="1"/>';
699
+ }
700
+ parts += `<name val="${escapeXml(s.fontName ?? "Calibri")}"/>`;
701
+ return `<font>${parts}</font>`;
702
+ }
703
+
704
+ /** Produce a deduplication key for a CellStyle's borders. */
705
+ function borderKey(s: CellStyle): string {
706
+ const parts: string[] = [];
707
+ if (s.borderTop) parts.push(`t:${s.borderTop.style}:${s.borderTop.width}:${s.borderTop.color ?? ""}`);
708
+ if (s.borderRight) parts.push(`r:${s.borderRight.style}:${s.borderRight.width}:${s.borderRight.color ?? ""}`);
709
+ if (s.borderBottom) parts.push(`b:${s.borderBottom.style}:${s.borderBottom.width}:${s.borderBottom.color ?? ""}`);
710
+ if (s.borderLeft) parts.push(`l:${s.borderLeft.style}:${s.borderLeft.width}:${s.borderLeft.color ?? ""}`);
711
+ if (s.borderDiagonal) parts.push(`d:${s.borderDiagonal.style}:${s.borderDiagonal.width}:${s.borderDiagonal.color ?? ""}`);
712
+ if (s.diagonalUp) parts.push("du");
713
+ if (s.diagonalDown) parts.push("dd");
714
+ return parts.join("|");
715
+ }
716
+
717
+ /**
718
+ * Convert a CSS-like BorderStyle back to an OOXML border style name.
719
+ * The parser maps OOXML → CSS (thin→solid/1, medium→solid/2, thick→solid/3).
720
+ * This reverses that mapping.
721
+ */
722
+ function toOoxmlBorderStyle(border: { width: number; style: string }): string {
723
+ // If the style is already an OOXML name, pass through
724
+ const ooxmlStyles = ["thin", "medium", "thick", "dotted", "dashed", "double", "hair",
725
+ "mediumDashed", "dashDot", "mediumDashDot", "dashDotDot", "mediumDashDotDot", "slantDashDot"];
726
+ if (ooxmlStyles.includes(border.style)) return border.style;
727
+
728
+ // Reverse the CSS mapping from ooxml_styles.ts
729
+ if (border.style === "solid") {
730
+ if (border.width <= 1) return "thin";
731
+ if (border.width <= 2) return "medium";
732
+ return "thick";
733
+ }
734
+ if (border.style === "dashed") return "dashed";
735
+ if (border.style === "dotted") return "dotted";
736
+ if (border.style === "double") return "double";
737
+ return "thin";
738
+ }
739
+
740
+ /** Build OOXML <border> element from a CellStyle. */
741
+ function buildBorderXml(s: CellStyle): string {
742
+ let attrs = "";
743
+ if (s.diagonalUp) attrs += ' diagonalUp="1"';
744
+ if (s.diagonalDown) attrs += ' diagonalDown="1"';
745
+
746
+ const sides = [
747
+ { tag: "left", border: s.borderLeft },
748
+ { tag: "right", border: s.borderRight },
749
+ { tag: "top", border: s.borderTop },
750
+ { tag: "bottom", border: s.borderBottom },
751
+ { tag: "diagonal", border: s.borderDiagonal },
752
+ ];
753
+
754
+ const inner = sides.map(({ tag, border }) => {
755
+ if (!border) return `<${tag}/>`;
756
+ const ooxmlStyle = toOoxmlBorderStyle(border);
757
+ let colorXml = "";
758
+ if (border.color) {
759
+ colorXml = `<color rgb="${hexToArgb(border.color)}"/>`;
760
+ }
761
+ return `<${tag} style="${ooxmlStyle}">${colorXml}</${tag}>`;
762
+ }).join("");
763
+
764
+ return `<border${attrs}>${inner}</border>`;
765
+ }
766
+
767
+ /** Produce a deduplication key for a CellStyle's fill. */
768
+ function fillKey(s: CellStyle): string {
769
+ if (s.gradientData) return `gradient:${JSON.stringify(s.gradientData)}`;
770
+ if (s.patternType && s.backgroundPattern) return `pattern:${s.patternType}|${s.backgroundPattern}`;
771
+ if (s.backgroundColor) return `solid:${s.backgroundColor}`;
772
+ return "";
773
+ }
774
+
775
+ /** Build OOXML <fill> element from a CellStyle. */
776
+ function buildFillXml(s: CellStyle): string {
777
+ if (s.gradientData) {
778
+ const g = s.gradientData;
779
+ const stops = g.stops.map((stop) => {
780
+ const hex = hexToArgb(stop.color);
781
+ return `<stop position="${stop.position}"><color rgb="${hex}"/></stop>`;
782
+ }).join("");
783
+ if (g.type === "radial") {
784
+ return `<fill><gradientFill type="path" left="0.5" right="0.5" top="0.5" bottom="0.5">${stops}</gradientFill></fill>`;
785
+ }
786
+ return `<fill><gradientFill degree="${g.degree}">${stops}</gradientFill></fill>`;
787
+ }
788
+
789
+ if (s.patternType && s.backgroundPattern) {
790
+ const colors = extractFillColors(s.backgroundPattern);
791
+ let colorAttrs = "";
792
+ if (colors.fg) colorAttrs += `<fgColor rgb="${hexToArgb(colors.fg)}"/>`;
793
+ if (colors.bg) colorAttrs += `<bgColor rgb="${hexToArgb(colors.bg)}"/>`;
794
+ return `<fill><patternFill patternType="${escapeXml(s.patternType)}">${colorAttrs}</patternFill></fill>`;
795
+ }
796
+
797
+ // Solid fill
798
+ return `<fill><patternFill patternType="solid"><fgColor rgb="${hexToArgb(s.backgroundColor)}"/></patternFill></fill>`;
799
+ }
800
+
801
+ /** Extract fg/bg colors from a CSS pattern string. */
802
+ function extractFillColors(css: string): { fg: string | undefined; bg: string | undefined } {
803
+ const colorMatches = css.match(/rgba?\([^)]+\)/g);
804
+ if (colorMatches) {
805
+ return { fg: colorMatches[0], bg: colorMatches[1] };
806
+ }
807
+ const hexMatches = css.match(/#[0-9a-fA-F]{6}/g);
808
+ if (hexMatches) {
809
+ return { fg: hexMatches[0], bg: hexMatches[1] };
810
+ }
811
+ return { fg: undefined, bg: undefined };
812
+ }
813
+
814
+ // =============================================================================
815
+ // Sheet XML
816
+ // =============================================================================
817
+
818
+ function buildSheetXml(
819
+ sheet: SheetModel,
820
+ styles: WorkbookModel["styles"],
821
+ ssIndex: Map<string, number>,
822
+ isActive: boolean,
823
+ xfMap: Map<string, number>,
824
+ numFmtMap: Map<string, number>,
825
+ dxfMap: Map<string, number>,
826
+ drawingRId?: string,
827
+ tableRIds?: string[],
828
+ hyperlinkRIds?: Map<string, string>,
829
+ ): string {
830
+ const rows: string[] = [];
831
+
832
+ // Group cells by row
833
+ const rowMap = new Map<number, Array<{ col: number; cell: CellModel }>>();
834
+ for (const [ref, cell] of sheet.cells) {
835
+ const rc = refToRowCol(ref);
836
+ if (!rc) continue;
837
+ let arr = rowMap.get(rc.row);
838
+ if (!arr) {
839
+ arr = [];
840
+ rowMap.set(rc.row, arr);
841
+ }
842
+ arr.push({ col: rc.col, cell });
843
+ }
844
+
845
+ const sortedRows = Array.from(rowMap.keys()).sort((a, b) => a - b);
846
+ for (const rowNum of sortedRows) {
847
+ const cells = rowMap.get(rowNum)!;
848
+ cells.sort((a, b) => a.col - b.col);
849
+
850
+ const h = sheet.rowHeights.get(rowNum);
851
+ const rowAttrs = h ? ` ht="${h}" customHeight="1"` : "";
852
+ const hidden = sheet.hiddenRows.has(rowNum) ? ' hidden="1"' : "";
853
+
854
+ const cellsXml = cells.map(({ col, cell }) => {
855
+ const ref = rowColToRef(rowNum, col);
856
+ const type = getCellType(cell, ssIndex);
857
+ const value = getCellValue(cell, ssIndex);
858
+ let attrs = `r="${ref}"`;
859
+
860
+ // Resolve the correct xf index. In styles-passthrough mode (xfMap is
861
+ // empty), use the cell's `originalXfIndex` captured at parse time.
862
+ // Otherwise compute via the rebuilt xfMap keyed by styleIndex+numFmtId.
863
+ let xfIndex = 0;
864
+ if (xfMap.size === 0 && cell.originalXfIndex !== undefined) {
865
+ xfIndex = cell.originalXfIndex;
866
+ } else {
867
+ const numFmtId = (cell.numFmtCode && cell.numFmtCode !== "General" && cell.numFmtCode !== "")
868
+ ? (numFmtMap.get(cell.numFmtCode) ?? 0)
869
+ : 0;
870
+ const xfKey = `${cell.styleIndex}:${numFmtId}`;
871
+ xfIndex = xfMap.get(xfKey) ?? 0;
872
+ }
873
+ if (xfIndex > 0) attrs += ` s="${xfIndex}"`;
874
+ if (type) attrs += ` t="${type}"`;
875
+
876
+ let inner = "";
877
+ if (cell.formula) {
878
+ if (cell.isArrayFormula && cell.arrayRange) {
879
+ inner += `<f t="array" ref="${cell.arrayRange}">${escapeXml(cell.formula)}</f>`;
880
+ } else {
881
+ inner += `<f>${escapeXml(cell.formula)}</f>`;
882
+ }
883
+ }
884
+ if (value !== undefined) inner += `<v>${escapeXml(String(value))}</v>`;
885
+ return `<c ${attrs}>${inner}</c>`;
886
+ }).join("");
887
+
888
+ rows.push(`<row r="${rowNum}"${rowAttrs}${hidden}>${cellsXml}</row>`);
889
+ }
890
+
891
+ // Column definitions
892
+ const cols: string[] = [];
893
+ const allCols = new Set([...sheet.colWidths.keys(), ...sheet.hiddenCols]);
894
+ for (const c of Array.from(allCols).sort((a, b) => a - b)) {
895
+ const w = sheet.colWidths.get(c) ?? sheet.defaultColWidth;
896
+ const hidden = sheet.hiddenCols.has(c) ? ' hidden="1"' : "";
897
+ cols.push(`<col min="${c}" max="${c}" width="${w}" customWidth="1"${hidden}/>`);
898
+ }
899
+
900
+ // Merged cells
901
+ let mergeXml = "";
902
+ if (sheet.mergedCells.length > 0) {
903
+ const merges = sheet.mergedCells.map((r) => `<mergeCell ref="${r}"/>`).join("");
904
+ mergeXml = `<mergeCells count="${sheet.mergedCells.length}">${merges}</mergeCells>`;
905
+ }
906
+
907
+ // Freeze pane + selection
908
+ let paneXml = "";
909
+ let selectionXml = `<selection activeCell="A1" sqref="A1"/>`;
910
+ if (sheet.freeze) {
911
+ const activePane = sheet.freeze.col > 0 && sheet.freeze.row > 0
912
+ ? "bottomRight"
913
+ : sheet.freeze.row > 0 ? "bottomLeft" : "topRight";
914
+ const topLeft = rowColToRef(sheet.freeze.row + 1, sheet.freeze.col + 1);
915
+ paneXml = `<pane xSplit="${sheet.freeze.col}" ySplit="${sheet.freeze.row}" topLeftCell="${topLeft}" activePane="${activePane}" state="frozen"/>`;
916
+ selectionXml = `<selection pane="${activePane}" activeCell="${topLeft}" sqref="${topLeft}"/>`;
917
+ }
918
+
919
+ const tabSelected = isActive ? ' tabSelected="1"' : '';
920
+ const sheetView = `<sheetView${tabSelected} workbookViewId="0"${!sheet.view.showGridLines ? ' showGridLines="0"' : ""}>${paneXml}${selectionXml}</sheetView>`;
921
+
922
+ // Auto-filter
923
+ let autoFilterXml = "";
924
+ if (sheet.autoFilter) {
925
+ let filterCols = "";
926
+ for (const col of sheet.autoFilter.columns) {
927
+ if (col.filterValues && col.filterValues.length > 0) {
928
+ const filters = col.filterValues.map((v) => `<filter val="${escapeXml(v)}"/>`).join("");
929
+ filterCols += `<filterColumn colId="${col.colIndex}"><filters>${filters}</filters></filterColumn>`;
930
+ }
931
+ }
932
+ autoFilterXml = `<autoFilter ref="${sheet.autoFilter.ref}">${filterCols}</autoFilter>`;
933
+ }
934
+
935
+ // Data validations
936
+ let dvXml = "";
937
+ if (sheet.dataValidations.length > 0) {
938
+ const dvEntries = sheet.dataValidations.map((dv) => {
939
+ let attrs = `sqref="${dv.ref}" type="${dv.type}"`;
940
+ if (dv.operator) attrs += ` operator="${dv.operator}"`;
941
+ if (!dv.showDropdown) attrs += ' showDropDown="1"';
942
+ if (dv.errorStyle) attrs += ` errorStyle="${dv.errorStyle}"`;
943
+ if (dv.errorTitle) attrs += ` errorTitle="${escapeXml(dv.errorTitle)}"`;
944
+ if (dv.errorMessage) attrs += ` error="${escapeXml(dv.errorMessage)}"`;
945
+ if (dv.promptTitle) attrs += ` promptTitle="${escapeXml(dv.promptTitle)}"`;
946
+ if (dv.promptMessage) attrs += ` prompt="${escapeXml(dv.promptMessage)}"`;
947
+ let inner = "";
948
+ if (dv.formula1) inner += `<formula1>${escapeXml(dv.formula1)}</formula1>`;
949
+ if (dv.formula2) inner += `<formula2>${escapeXml(dv.formula2)}</formula2>`;
950
+ return `<dataValidation ${attrs}>${inner}</dataValidation>`;
951
+ }).join("");
952
+ dvXml = `<dataValidations count="${sheet.dataValidations.length}">${dvEntries}</dataValidations>`;
953
+ }
954
+
955
+ // Conditional formatting
956
+ let cfXml = "";
957
+ if (sheet.conditionalFormats.length > 0) {
958
+ cfXml = sheet.conditionalFormats.map((cf) => buildConditionalFormattingXml(cf, dxfMap)).join("");
959
+ }
960
+
961
+ // Hyperlinks
962
+ let hyperlinksXml = "";
963
+ if (hyperlinkRIds && hyperlinkRIds.size > 0) {
964
+ const hlEntries = Array.from(hyperlinkRIds.entries())
965
+ .map(([ref, rId]) => `<hyperlink ref="${ref}" r:id="${rId}"/>`)
966
+ .join("");
967
+ hyperlinksXml = `<hyperlinks>${hlEntries}</hyperlinks>`;
968
+ }
969
+
970
+ // Drawing reference
971
+ const drawingXml = drawingRId ? `<drawing r:id="${drawingRId}"/>` : "";
972
+
973
+ // Table parts
974
+ let tablePartsXml = "";
975
+ if (tableRIds && tableRIds.length > 0) {
976
+ const parts = tableRIds.map((rId) => `<tablePart r:id="${rId}"/>`).join("");
977
+ tablePartsXml = `<tableParts count="${tableRIds.length}">${parts}</tableParts>`;
978
+ }
979
+
980
+ // Page setup + header/footer
981
+ const ps = sheet.pageSetup;
982
+ const hf = sheet.headerFooter;
983
+ const fitToPage = ps && (ps.fitToWidth !== undefined || ps.fitToHeight !== undefined);
984
+ const sheetPrXml = fitToPage
985
+ ? `<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr>`
986
+ : "";
987
+
988
+ const marginsXml = (() => {
989
+ const m = ps?.margins;
990
+ const left = m?.left ?? 0.7;
991
+ const right = m?.right ?? 0.7;
992
+ const top = m?.top ?? 0.75;
993
+ const bottom = m?.bottom ?? 0.75;
994
+ const header = m?.header ?? 0.3;
995
+ const footer = m?.footer ?? 0.3;
996
+ return `<pageMargins left="${left}" right="${right}" top="${top}" bottom="${bottom}" header="${header}" footer="${footer}"/>`;
997
+ })();
998
+
999
+ let pageSetupXml = "";
1000
+ if (ps) {
1001
+ const attrs: string[] = [];
1002
+ if (ps.paperSize !== undefined) attrs.push(`paperSize="${ps.paperSize}"`);
1003
+ if (ps.scale !== undefined) attrs.push(`scale="${ps.scale}"`);
1004
+ if (ps.fitToWidth !== undefined) attrs.push(`fitToWidth="${ps.fitToWidth}"`);
1005
+ if (ps.fitToHeight !== undefined) attrs.push(`fitToHeight="${ps.fitToHeight}"`);
1006
+ if (ps.orientation) attrs.push(`orientation="${ps.orientation}"`);
1007
+ if (attrs.length > 0) pageSetupXml = `<pageSetup ${attrs.join(" ")}/>`;
1008
+ }
1009
+
1010
+ let headerFooterXml = "";
1011
+ if (hf) {
1012
+ const rootAttrs: string[] = [];
1013
+ if (hf.differentOddEven) rootAttrs.push(`differentOddEven="1"`);
1014
+ if (hf.differentFirst) rootAttrs.push(`differentFirst="1"`);
1015
+ const inner: string[] = [];
1016
+ if (hf.oddHeader) inner.push(`<oddHeader>${escapeXml(hf.oddHeader)}</oddHeader>`);
1017
+ if (hf.oddFooter) inner.push(`<oddFooter>${escapeXml(hf.oddFooter)}</oddFooter>`);
1018
+ if (hf.evenHeader) inner.push(`<evenHeader>${escapeXml(hf.evenHeader)}</evenHeader>`);
1019
+ if (hf.evenFooter) inner.push(`<evenFooter>${escapeXml(hf.evenFooter)}</evenFooter>`);
1020
+ if (hf.firstHeader) inner.push(`<firstHeader>${escapeXml(hf.firstHeader)}</firstHeader>`);
1021
+ if (hf.firstFooter) inner.push(`<firstFooter>${escapeXml(hf.firstFooter)}</firstFooter>`);
1022
+ if (inner.length > 0) {
1023
+ const attrStr = rootAttrs.length > 0 ? ` ${rootAttrs.join(" ")}` : "";
1024
+ headerFooterXml = `<headerFooter${attrStr}>${inner.join("")}</headerFooter>`;
1025
+ }
1026
+ }
1027
+
1028
+ // Compute dimension from cell extent
1029
+ let dimensionRef = "A1";
1030
+ if (sortedRows.length > 0) {
1031
+ let minCol = Infinity, maxCol = 0;
1032
+ const minRow = sortedRows[0];
1033
+ const maxRow = sortedRows[sortedRows.length - 1];
1034
+ for (const rowNum of sortedRows) {
1035
+ const cells = rowMap.get(rowNum)!;
1036
+ for (const { col } of cells) {
1037
+ if (col < minCol) minCol = col;
1038
+ if (col > maxCol) maxCol = col;
1039
+ }
1040
+ }
1041
+ dimensionRef = `${rowColToRef(minRow, minCol)}:${rowColToRef(maxRow, maxCol)}`;
1042
+ }
1043
+
1044
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1045
+ <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
1046
+ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1047
+ ${sheetPrXml}<dimension ref="${dimensionRef}"/>
1048
+ <sheetViews>${sheetView}</sheetViews>
1049
+ <sheetFormatPr defaultRowHeight="${sheet.defaultRowHeight}" defaultColWidth="${sheet.defaultColWidth}"/>
1050
+ ${cols.length > 0 ? `<cols>${cols.join("")}</cols>` : ""}
1051
+ <sheetData>
1052
+ ${rows.join("\n")}
1053
+ </sheetData>
1054
+ ${autoFilterXml}${mergeXml}${cfXml}${dvXml}${hyperlinksXml}${marginsXml}${pageSetupXml}${headerFooterXml}${drawingXml}${tablePartsXml}
1055
+ </worksheet>`;
1056
+ }
1057
+
1058
+ function getCellType(cell: CellModel, ssIndex: Map<string, number>): string | undefined {
1059
+ if (cell.error) return "e";
1060
+ if (cell.formula && typeof cell.value === "string") return "str";
1061
+ if (typeof cell.value === "string" && ssIndex.has(cell.value)) return "s";
1062
+ if (typeof cell.value === "boolean") return "b";
1063
+ return undefined;
1064
+ }
1065
+
1066
+ function getCellValue(cell: CellModel, ssIndex: Map<string, number>): string | number | undefined {
1067
+ if (cell.value === null) return undefined;
1068
+ if (cell.error) return cell.error;
1069
+ if (cell.formula && typeof cell.value === "string") return cell.value;
1070
+ if (typeof cell.value === "string") {
1071
+ const idx = ssIndex.get(cell.value);
1072
+ return idx !== undefined ? idx : cell.value;
1073
+ }
1074
+ if (typeof cell.value === "boolean") return cell.value ? 1 : 0;
1075
+ return cell.value;
1076
+ }
1077
+
1078
+ // =============================================================================
1079
+ // Workbook XML
1080
+ // =============================================================================
1081
+
1082
+ /** Wrap sheet name in single quotes if it contains non-alphanumeric chars. */
1083
+ function quoteSheetName(name: string): string {
1084
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
1085
+ ? name
1086
+ : `'${name.replace(/'/g, "''")}'`;
1087
+ }
1088
+
1089
+ function buildWorkbookXml(workbook: WorkbookModel, pivotInfo?: PivotRoundTripInfo): string {
1090
+ const sheets = workbook.sheets.map((s, i) =>
1091
+ `<sheet name="${escapeXml(s.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`,
1092
+ ).join("\n");
1093
+
1094
+ const nameEntries: string[] = [];
1095
+
1096
+ for (const [name, value] of workbook.namedRanges) {
1097
+ nameEntries.push(`<definedName name="${escapeXml(name)}">${escapeXml(value)}</definedName>`);
1098
+ }
1099
+
1100
+ workbook.sheets.forEach((s, i) => {
1101
+ const pt = s.printTitles;
1102
+ if (!pt || (!pt.repeatRows && !pt.repeatCols)) return;
1103
+ const parts: string[] = [];
1104
+ const sheetName = quoteSheetName(s.name);
1105
+ if (pt.repeatCols) {
1106
+ const [start, end] = pt.repeatCols;
1107
+ parts.push(`${sheetName}!$${colNumToLetters(start)}:$${colNumToLetters(end)}`);
1108
+ }
1109
+ if (pt.repeatRows) {
1110
+ const [start, end] = pt.repeatRows;
1111
+ parts.push(`${sheetName}!$${start}:$${end}`);
1112
+ }
1113
+ nameEntries.push(
1114
+ `<definedName name="_xlnm.Print_Titles" localSheetId="${i}">${escapeXml(parts.join(","))}</definedName>`,
1115
+ );
1116
+ });
1117
+
1118
+ const definedNames = nameEntries.length > 0
1119
+ ? `\n<definedNames>\n${nameEntries.join("\n")}\n</definedNames>`
1120
+ : "";
1121
+
1122
+ // Re-emit <pivotCaches> if the original file had any. The rId for each
1123
+ // cache comes after the per-sheet rIds + styles + sharedStrings, mirroring
1124
+ // the order in `buildWorkbookRels`.
1125
+ let pivotCachesBlock = "";
1126
+ if (pivotInfo && pivotInfo.cachesByWorkbook.size > 0) {
1127
+ const baseRId = workbook.sheets.length + 3; // sheets + styles + sharedStrings
1128
+ const cacheIds = [...pivotInfo.cachesByWorkbook.keys()].sort((a, b) => a - b);
1129
+ const items = cacheIds
1130
+ .map((cacheId, i) => `<pivotCache cacheId="${cacheId}" r:id="rId${baseRId + i}"/>`)
1131
+ .join("\n");
1132
+ pivotCachesBlock = `\n<pivotCaches>\n${items}\n</pivotCaches>`;
1133
+ }
1134
+
1135
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1136
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
1137
+ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1138
+ <bookViews>
1139
+ <workbookView activeTab="${workbook.activeSheetIndex}"/>
1140
+ </bookViews>
1141
+ <sheets>
1142
+ ${sheets}
1143
+ </sheets>${definedNames}${pivotCachesBlock}
1144
+ <calcPr fullCalcOnLoad="1"/>
1145
+ </workbook>`;
1146
+ }
1147
+
1148
+ function buildWorkbookRels(
1149
+ workbook: WorkbookModel,
1150
+ pivotInfo?: PivotRoundTripInfo,
1151
+ ): string {
1152
+ const rels = workbook.sheets.map((_, i) =>
1153
+ `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`,
1154
+ );
1155
+ rels.push(`<Relationship Id="rId${workbook.sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
1156
+ rels.push(`<Relationship Id="rId${workbook.sheets.length + 2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>`);
1157
+
1158
+ if (pivotInfo && pivotInfo.cachesByWorkbook.size > 0) {
1159
+ const baseRId = workbook.sheets.length + 3;
1160
+ const cacheIds = [...pivotInfo.cachesByWorkbook.keys()].sort((a, b) => a - b);
1161
+ cacheIds.forEach((cacheId, i) => {
1162
+ const target = pivotInfo.cachesByWorkbook.get(cacheId);
1163
+ if (!target) return;
1164
+ rels.push(
1165
+ `<Relationship Id="rId${baseRId + i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition" Target="${escapeXml(target)}"/>`,
1166
+ );
1167
+ });
1168
+ }
1169
+
1170
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1171
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1172
+ ${rels.join("\n")}
1173
+ </Relationships>`;
1174
+ }
1175
+
1176
+ // =============================================================================
1177
+ // Content Types
1178
+ // =============================================================================
1179
+
1180
+ function buildContentTypes(sheetCount: number, extraTypes: string[] = []): string {
1181
+ const sheetTypes = Array.from({ length: sheetCount }, (_, i) =>
1182
+ `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
1183
+ ).join("\n");
1184
+
1185
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1186
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
1187
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
1188
+ <Default Extension="xml" ContentType="application/xml"/>
1189
+ <Default Extension="png" ContentType="image/png"/>
1190
+ <Default Extension="jpeg" ContentType="image/jpeg"/>
1191
+ <Default Extension="jpg" ContentType="image/jpeg"/>
1192
+ <Default Extension="gif" ContentType="image/gif"/>
1193
+ <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
1194
+ ${sheetTypes}
1195
+ <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
1196
+ <Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
1197
+ <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
1198
+ <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
1199
+ ${extraTypes.join("\n")}
1200
+ </Types>`;
1201
+ }
1202
+
1203
+ function buildRootRels(): string {
1204
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1205
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1206
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
1207
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
1208
+ <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
1209
+ </Relationships>`;
1210
+ }
1211
+
1212
+ function buildCoreProps(): string {
1213
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
1214
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1215
+ <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
1216
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
1217
+ xmlns:dcterms="http://purl.org/dc/terms/"
1218
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
1219
+ <dcterms:created xsi:type="dcterms:W3CDTF">${now}</dcterms:created>
1220
+ <dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified>
1221
+ </cp:coreProperties>`;
1222
+ }
1223
+
1224
+ function buildAppProps(): string {
1225
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1226
+ <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
1227
+ <Application>Microsoft Excel</Application>
1228
+ </Properties>`;
1229
+ }
1230
+
1231
+ // =============================================================================
1232
+ // Chart XML
1233
+ // =============================================================================
1234
+
1235
+ const CHART_TYPE_MAP: Record<string, string> = {
1236
+ bar: "c:barChart", col: "c:barChart", line: "c:lineChart", pie: "c:pieChart",
1237
+ doughnut: "c:doughnutChart", area: "c:areaChart", scatter: "c:scatterChart",
1238
+ bubble: "c:bubbleChart", radar: "c:radarChart", stock: "c:stockChart",
1239
+ surface: "c:surfaceChart",
1240
+ };
1241
+
1242
+ function buildSeriesXml(s: ChartSeries, idx: number, chartType: ChartType, categories?: string[]): string {
1243
+ let nameXml = "";
1244
+ if (s.name) nameXml = `<c:tx><c:strRef><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>${escapeXml(s.name)}</c:v></c:pt></c:strCache></c:strRef></c:tx>`;
1245
+
1246
+ let colorXml = "";
1247
+ if (s.color) colorXml = `<c:spPr><a:solidFill><a:srgbClr val="${hexToOoxmlRgb(s.color)}"/></a:solidFill></c:spPr>`;
1248
+
1249
+ const valTag = chartType === "scatter" || chartType === "bubble" ? "c:yVal" : "c:val";
1250
+ const valPts = s.values.map((v, i) => `<c:pt idx="${i}"><c:v>${v}</c:v></c:pt>`).join("");
1251
+ const valXml = `<${valTag}><c:numRef><c:numCache><c:ptCount val="${s.values.length}"/>${valPts}</c:numCache></c:numRef></${valTag}>`;
1252
+
1253
+ let catXml = "";
1254
+ if (categories && categories.length > 0) {
1255
+ const catTag = chartType === "scatter" || chartType === "bubble" ? "c:xVal" : "c:cat";
1256
+ const catPts = categories.map((c, i) => `<c:pt idx="${i}"><c:v>${escapeXml(c)}</c:v></c:pt>`).join("");
1257
+ catXml = `<${catTag}><c:strRef><c:strCache><c:ptCount val="${categories.length}"/>${catPts}</c:strCache></c:strRef></${catTag}>`;
1258
+ }
1259
+
1260
+ let bubbleXml = "";
1261
+ if (s.bubbleSizes) {
1262
+ const bPts = s.bubbleSizes.map((v, i) => `<c:pt idx="${i}"><c:v>${v}</c:v></c:pt>`).join("");
1263
+ bubbleXml = `<c:bubbleSize><c:numRef><c:numCache><c:ptCount val="${s.bubbleSizes.length}"/>${bPts}</c:numCache></c:numRef></c:bubbleSize>`;
1264
+ }
1265
+
1266
+ return `<c:ser><c:idx val="${idx}"/><c:order val="${idx}"/>${nameXml}${colorXml}${catXml}${valXml}${bubbleXml}</c:ser>`;
1267
+ }
1268
+
1269
+ function buildChartTypeElement(chartType: ChartType, seriesXml: string, needsAxIds: boolean): string {
1270
+ const chartTag = CHART_TYPE_MAP[chartType] ?? "c:barChart";
1271
+ const isBar = chartType === "bar";
1272
+
1273
+ let barDir = "";
1274
+ if (chartTag === "c:barChart") {
1275
+ barDir = isBar ? '<c:barDir val="bar"/>' : '<c:barDir val="col"/>';
1276
+ }
1277
+
1278
+ let grouping = "";
1279
+ if (chartTag === "c:barChart") {
1280
+ grouping = '<c:grouping val="clustered"/>';
1281
+ } else if (chartTag === "c:lineChart" || chartTag === "c:areaChart") {
1282
+ grouping = '<c:grouping val="clustered"/>';
1283
+ }
1284
+
1285
+ const axIds = needsAxIds ? '<c:axId val="1"/><c:axId val="2"/>' : "";
1286
+
1287
+ return `<${chartTag}>${barDir}${grouping}${seriesXml}${axIds}</${chartTag}>`;
1288
+ }
1289
+
1290
+ function buildChartXml(chart: ParsedChart): string {
1291
+ const needsAxIds = chart.chartType !== "pie" && chart.chartType !== "doughnut";
1292
+
1293
+ let plotArea: string;
1294
+
1295
+ if (chart.chartType === "combo") {
1296
+ // Group series by their seriesChartType
1297
+ const groups = new Map<ChartType, { series: ChartSeries; idx: number }[]>();
1298
+ chart.series.forEach((s, idx) => {
1299
+ const sType = s.seriesChartType ?? "col";
1300
+ let group = groups.get(sType);
1301
+ if (!group) {
1302
+ group = [];
1303
+ groups.set(sType, group);
1304
+ }
1305
+ group.push({ series: s, idx });
1306
+ });
1307
+
1308
+ let chartElements = "";
1309
+ for (const [groupType, groupSeries] of groups) {
1310
+ const groupSeriesXml = groupSeries.map(({ series, idx }) =>
1311
+ buildSeriesXml(series, idx, groupType, chart.categories),
1312
+ ).join("");
1313
+ chartElements += buildChartTypeElement(groupType, groupSeriesXml, true);
1314
+ }
1315
+
1316
+ plotArea = `<c:plotArea><c:layout/>${chartElements}`;
1317
+ } else {
1318
+ const seriesXml = chart.series.map((s, idx) =>
1319
+ buildSeriesXml(s, idx, chart.chartType, chart.categories),
1320
+ ).join("");
1321
+ plotArea = `<c:plotArea><c:layout/>${buildChartTypeElement(chart.chartType, seriesXml, needsAxIds)}`;
1322
+ }
1323
+
1324
+ // Axes (not for pie/doughnut)
1325
+ if (chart.chartType !== "pie" && chart.chartType !== "doughnut") {
1326
+ const axes = chart.axes ?? [{ type: "category" as const }, { type: "value" as const }];
1327
+ for (let i = 0; i < axes.length; i++) {
1328
+ const ax = axes[i];
1329
+ const axId = i + 1;
1330
+ const crossId = i === 0 ? 2 : 1;
1331
+ let axTag: string;
1332
+ switch (ax.type) {
1333
+ case "value": axTag = "c:valAx"; break;
1334
+ case "date": axTag = "c:dateAx"; break;
1335
+ case "series": axTag = "c:serAx"; break;
1336
+ default: axTag = "c:catAx"; break;
1337
+ }
1338
+ let titleXml = "";
1339
+ if (ax.title) titleXml = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapeXml(ax.title)}</a:t></a:r></a:p></c:rich></c:tx></c:title>`;
1340
+ let scalingXml = "<c:scaling><c:orientation val=\"minMax\"/>";
1341
+ if (ax.min !== undefined) scalingXml += `<c:min val="${ax.min}"/>`;
1342
+ if (ax.max !== undefined) scalingXml += `<c:max val="${ax.max}"/>`;
1343
+ scalingXml += "</c:scaling>";
1344
+ const numFmt = ax.numFmt ? `<c:numFmt formatCode="${escapeXml(ax.numFmt)}" sourceLinked="0"/>` : "";
1345
+ plotArea += `<${axTag}><c:axId val="${axId}"/>${scalingXml}${titleXml}${numFmt}<c:crossAx val="${crossId}"/></${axTag}>`;
1346
+ }
1347
+ }
1348
+ plotArea += "</c:plotArea>";
1349
+
1350
+ let titleXml = "";
1351
+ if (chart.title) {
1352
+ titleXml = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapeXml(chart.title)}</a:t></a:r></a:p></c:rich></c:tx></c:title>`;
1353
+ }
1354
+
1355
+ let legendXml = "";
1356
+ if (chart.legendPosition && chart.legendPosition !== "none") {
1357
+ const posMap: Record<string, string> = { top: "t", bottom: "b", left: "l", right: "r" };
1358
+ legendXml = `<c:legend><c:legendPos val="${posMap[chart.legendPosition] ?? "b"}"/></c:legend>`;
1359
+ }
1360
+
1361
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1362
+ <c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1363
+ <c:chart>${titleXml}${plotArea}${legendXml}</c:chart>
1364
+ </c:chartSpace>`;
1365
+ }
1366
+
1367
+ // =============================================================================
1368
+ // Drawing anchors (charts, images, shapes)
1369
+ // =============================================================================
1370
+
1371
+ function buildAnchorPosition(pos: { row: number; col: number; rowOffset?: number; colOffset?: number }): string {
1372
+ return `<xdr:col>${pos.col}</xdr:col><xdr:colOff>${pos.colOffset ?? 0}</xdr:colOff><xdr:row>${pos.row}</xdr:row><xdr:rowOff>${pos.rowOffset ?? 0}</xdr:rowOff>`;
1373
+ }
1374
+
1375
+ function buildChartAnchorXml(chart: ParsedChart, rId: string): string {
1376
+ return `<xdr:twoCellAnchor>
1377
+ <xdr:from>${buildAnchorPosition(chart.anchor.from)}</xdr:from>
1378
+ <xdr:to>${buildAnchorPosition(chart.anchor.to)}</xdr:to>
1379
+ <xdr:graphicFrame macro="">
1380
+ <xdr:nvGraphicFramePr><xdr:cNvPr id="0" name="Chart"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>
1381
+ <xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>
1382
+ <a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="${rId}"/></a:graphicData></a:graphic>
1383
+ </xdr:graphicFrame>
1384
+ <xdr:clientData/>
1385
+ </xdr:twoCellAnchor>`;
1386
+ }
1387
+
1388
+ function buildImageAnchorXml(image: { tl: { row: number; col: number }; br: { row: number; col: number } }, rId: string): string {
1389
+ return `<xdr:twoCellAnchor editAs="oneCell">
1390
+ <xdr:from>${buildAnchorPosition(image.tl)}</xdr:from>
1391
+ <xdr:to>${buildAnchorPosition(image.br)}</xdr:to>
1392
+ <xdr:pic>
1393
+ <xdr:nvPicPr><xdr:cNvPr id="0" name="Image"/><xdr:cNvPicPr/></xdr:nvPicPr>
1394
+ <xdr:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="${rId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill>
1395
+ <xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr>
1396
+ </xdr:pic>
1397
+ <xdr:clientData/>
1398
+ </xdr:twoCellAnchor>`;
1399
+ }
1400
+
1401
+ function buildShapeAnchorXml(drawing: ParsedDrawing): string {
1402
+ let fillXml = "";
1403
+ if (drawing.fillColor) fillXml = `<a:solidFill><a:srgbClr val="${hexToOoxmlRgb(drawing.fillColor)}"/></a:solidFill>`;
1404
+
1405
+ let outlineXml = "";
1406
+ if (drawing.outlineColor) {
1407
+ const w = Math.round((drawing.outlineWidth ?? 1) * 12700); // points to EMU
1408
+ outlineXml = `<a:ln w="${w}"><a:solidFill><a:srgbClr val="${hexToOoxmlRgb(drawing.outlineColor)}"/></a:solidFill></a:ln>`;
1409
+ }
1410
+
1411
+ const geom = drawing.geometry ?? "rect";
1412
+ let textXml = "";
1413
+ if (drawing.text) textXml = `<xdr:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapeXml(drawing.text)}</a:t></a:r></a:p></xdr:txBody>`;
1414
+
1415
+ return `<xdr:twoCellAnchor>
1416
+ <xdr:from>${buildAnchorPosition(drawing.anchor.from)}</xdr:from>
1417
+ <xdr:to>${buildAnchorPosition(drawing.anchor.to)}</xdr:to>
1418
+ <xdr:sp><xdr:nvSpPr><xdr:cNvPr id="0" name="Shape"/><xdr:cNvSpPr/></xdr:nvSpPr>
1419
+ <xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></a:xfrm><a:prstGeom prst="${geom}"><a:avLst/></a:prstGeom>${fillXml}${outlineXml}</xdr:spPr>
1420
+ ${textXml}</xdr:sp>
1421
+ <xdr:clientData/>
1422
+ </xdr:twoCellAnchor>`;
1423
+ }
1424
+
1425
+ // =============================================================================
1426
+ // Table XML
1427
+ // =============================================================================
1428
+
1429
+ function buildTableXml(table: ParsedTable, tableId: number): string {
1430
+ const colsXml = table.columns.map((col) => {
1431
+ let inner = "";
1432
+ if (col.totalsFunction) inner += `<totalsRowFunction>${escapeXml(col.totalsFunction)}</totalsRowFunction>`;
1433
+ if (col.totalsFormula) inner += `<totalsRowFormula>${escapeXml(col.totalsFormula)}</totalsRowFormula>`;
1434
+ return `<tableColumn id="${col.id}" name="${escapeXml(col.name)}">${inner}</tableColumn>`;
1435
+ }).join("");
1436
+
1437
+ const autoFilterXml = table.autoFilter ? `<autoFilter ref="${escapeXml(table.ref)}"/>` : "";
1438
+ const styleName = table.styleName ?? "TableStyleMedium2";
1439
+
1440
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1441
+ <table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="${tableId}" name="${escapeXml(table.name)}" displayName="${escapeXml(table.displayName)}" ref="${escapeXml(table.ref)}" totalsRowCount="${table.totalsRow ? 1 : 0}">
1442
+ ${autoFilterXml}
1443
+ <tableColumns count="${table.columns.length}">${colsXml}</tableColumns>
1444
+ <tableStyleInfo name="${escapeXml(styleName)}" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>
1445
+ </table>`;
1446
+ }
1447
+
1448
+ // =============================================================================
1449
+ // Conditional Formatting XML
1450
+ // =============================================================================
1451
+
1452
+ function buildConditionalFormattingXml(cf: ParsedConditionalFormat, dxfMap: Map<string, number>): string {
1453
+ const rules = cf.rules.map((rule) => {
1454
+ switch (rule.ruleType) {
1455
+ case "colorScale": {
1456
+ const count = rule.colors.length;
1457
+ const cfvos = count === 2
1458
+ ? '<cfvo type="min"/><cfvo type="max"/>'
1459
+ : '<cfvo type="min"/><cfvo type="percentile" val="50"/><cfvo type="max"/>';
1460
+ const colors = rule.colors.map((c) => `<color rgb="${hexToArgb(c)}"/>`).join("");
1461
+ return `<cfRule type="colorScale" priority="${rule.priority}"><colorScale>${cfvos}${colors}</colorScale></cfRule>`;
1462
+ }
1463
+ case "dataBar": {
1464
+ const showVal = rule.showValue ? "1" : "0";
1465
+ return `<cfRule type="dataBar" priority="${rule.priority}"><dataBar minLength="${rule.minLength}" maxLength="${rule.maxLength}" showValue="${showVal}"><cfvo type="min"/><cfvo type="max"/><color rgb="${hexToArgb(rule.color)}"/></dataBar></cfRule>`;
1466
+ }
1467
+ case "iconSet": {
1468
+ const thresholds = rule.thresholds.map((t) => {
1469
+ if (t.type === "min" || t.type === "autoMin") return '<cfvo type="min"/>';
1470
+ if (t.type === "max" || t.type === "autoMax") return '<cfvo type="max"/>';
1471
+ return `<cfvo type="${t.type}" val="${t.value ?? 0}"/>`;
1472
+ }).join("");
1473
+ const showVal = rule.showValue ? "" : ' showValue="0"';
1474
+ const reverse = rule.reverse ? ' reverse="1"' : "";
1475
+ return `<cfRule type="iconSet" priority="${rule.priority}"><iconSet iconSet="${rule.iconSet}"${showVal}${reverse}>${thresholds}</iconSet></cfRule>`;
1476
+ }
1477
+ case "style": {
1478
+ let attrs = `type="${rule.type}" priority="${rule.priority}"`;
1479
+ if (rule.style) {
1480
+ const dxfId = dxfMap.get(JSON.stringify(rule.style));
1481
+ if (dxfId !== undefined) attrs += ` dxfId="${dxfId}"`;
1482
+ }
1483
+ if (rule.operator) attrs += ` operator="${rule.operator}"`;
1484
+ if (rule.text) attrs += ` text="${escapeXml(rule.text)}"`;
1485
+ if (rule.rank !== undefined) attrs += ` rank="${rule.rank}"`;
1486
+ if (rule.percent) attrs += ' percent="1"';
1487
+ if (rule.bottom) attrs += ' bottom="1"';
1488
+ if (rule.aboveAverage === false) attrs += ' aboveAverage="0"';
1489
+ if (rule.timePeriod) attrs += ` timePeriod="${rule.timePeriod}"`;
1490
+ let inner = "";
1491
+ if (rule.formulae) {
1492
+ inner = rule.formulae.map((f) => `<formula>${escapeXml(String(f))}</formula>`).join("");
1493
+ }
1494
+ return `<cfRule ${attrs}>${inner}</cfRule>`;
1495
+ }
1496
+ }
1497
+ }).join("");
1498
+
1499
+ return `<conditionalFormatting sqref="${cf.ref}">${rules}</conditionalFormatting>`;
1500
+ }
1501
+
1502
+ /** Build a differential formatting (dxf) entry for style-based CF rules. */
1503
+ function buildDxfXml(style: ParsedCfStyle): string {
1504
+ let inner = "";
1505
+ if (style.fontBold || style.fontItalic || style.fontColor) {
1506
+ let fontParts = "";
1507
+ if (style.fontBold) fontParts += "<b/>";
1508
+ if (style.fontItalic) fontParts += "<i/>";
1509
+ if (style.fontColor) fontParts += `<color rgb="${hexToArgb(style.fontColor)}"/>`;
1510
+ inner += `<font>${fontParts}</font>`;
1511
+ }
1512
+ if (style.backgroundColor) {
1513
+ inner += `<fill><patternFill><bgColor rgb="${hexToArgb(style.backgroundColor)}"/></patternFill></fill>`;
1514
+ }
1515
+ return `<dxf>${inner}</dxf>`;
1516
+ }
1517
+
1518
+ // =============================================================================
1519
+ // Image helpers
1520
+ // =============================================================================
1521
+
1522
+ function getImageExtension(dataUrl: string): string {
1523
+ if (dataUrl.startsWith("data:image/png")) return "png";
1524
+ if (dataUrl.startsWith("data:image/jpeg") || dataUrl.startsWith("data:image/jpg")) return "jpeg";
1525
+ if (dataUrl.startsWith("data:image/gif")) return "gif";
1526
+ return "png"; // default
1527
+ }
1528
+
1529
+ function dataUrlToBytes(dataUrl: string): Uint8Array | null {
1530
+ const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
1531
+ if (!match) return null;
1532
+ const binary = atob(match[1]);
1533
+ const bytes = new Uint8Array(binary.length);
1534
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1535
+ return bytes;
1536
+ }
1537
+
1538
+ // =============================================================================
1539
+ // Helpers
1540
+ // =============================================================================
1541
+
1542
+ function escapeXml(s: string): string {
1543
+ return s
1544
+ .replace(/&/g, "&amp;")
1545
+ .replace(/</g, "&lt;")
1546
+ .replace(/>/g, "&gt;")
1547
+ .replace(/"/g, "&quot;")
1548
+ .replace(/'/g, "&apos;");
1549
+ }