@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,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, "&")
|
|
1545
|
+
.replace(/</g, "<")
|
|
1546
|
+
.replace(/>/g, ">")
|
|
1547
|
+
.replace(/"/g, """)
|
|
1548
|
+
.replace(/'/g, "'");
|
|
1549
|
+
}
|