@lotics/xlsx 0.1.3 → 0.1.5

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.
@@ -0,0 +1,340 @@
1
+ // =============================================================================
2
+ // OOXML Pivot Table Writer
3
+ // =============================================================================
4
+ //
5
+ // Serializes AUTHORED pivots (built via `pivot_builder`) into the three OOXML
6
+ // parts Excel expects: `pivotTableN.xml`, `pivotCacheDefinitionN.xml`, and
7
+ // `pivotCacheRecordsN.xml`. The inverse of `ooxml_pivot.ts` (the parser); the
8
+ // two are kept format-compatible so a build → write → parse round-trip is
9
+ // lossless for the supported subset.
10
+ //
11
+ // Supported subset (v1): exactly one row field, one column field, one data
12
+ // field, and zero or more page (report-filter) fields, with grand totals. This
13
+ // is the 2D cross-tab shape; multi-level axes and multiple data fields are not
14
+ // emitted (the builder rejects them) so the row/col item layout stays a flat,
15
+ // verifiable list.
16
+ //
17
+ // Every authored cache carries `refreshOnLoad="1"`: the cache records below are
18
+ // a faithful snapshot of the source range, AND Excel rebuilds them from the
19
+ // live worksheet on open, so the pivot is correct in non-refreshing viewers and
20
+ // stays in sync after edits.
21
+ // =============================================================================
22
+
23
+ import { escapeXml } from "./excel_utils";
24
+ import type {
25
+ ParsedPivotCache,
26
+ ParsedPivotTable,
27
+ PivotCacheField,
28
+ PivotCacheItem,
29
+ } from "./ooxml_pivot_types";
30
+ import type { PivotSourceRecord } from "./pivot_recompute";
31
+
32
+ const MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
33
+ const REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
34
+
35
+ // =============================================================================
36
+ // Shared item helpers (used by both the builder and this writer)
37
+ // =============================================================================
38
+
39
+ /** Coerce a raw source cell value to its cache-item representation. */
40
+ export function sourceValueToItem(
41
+ v: string | number | boolean | null | undefined,
42
+ ): PivotCacheItem {
43
+ if (v === undefined || v === null || v === "") return { kind: "missing" };
44
+ if (typeof v === "number") return { kind: "number", value: v };
45
+ if (typeof v === "boolean") return { kind: "boolean", value: v };
46
+ return { kind: "string", value: v };
47
+ }
48
+
49
+ /** Canonical key for de-duplicating / looking up a cache item. */
50
+ export function cacheItemKey(item: PivotCacheItem): string {
51
+ switch (item.kind) {
52
+ case "missing":
53
+ return "m:";
54
+ case "number":
55
+ return `n:${item.value}`;
56
+ case "boolean":
57
+ return `b:${item.value}`;
58
+ case "string":
59
+ return `s:${item.value}`;
60
+ case "date":
61
+ return `d:${item.value}`;
62
+ case "error":
63
+ return `e:${item.value}`;
64
+ }
65
+ }
66
+
67
+ /** The label used for ordering — matches `pivot_recompute`'s String()-lexicographic
68
+ * tuple sort so the cache item order equals the pivot's displayed row/col order. */
69
+ export function cacheItemSortKey(item: PivotCacheItem): string {
70
+ return item.kind === "missing" ? "" : String(item.value);
71
+ }
72
+
73
+ /** Which fields sit on an axis (row/col/page) — i.e. carry enumerated shared
74
+ * items and reference them by index in the cache records. The data/unused
75
+ * columns inline their values instead. */
76
+ export function enumerateFlags(table: ParsedPivotTable, fieldCount: number): boolean[] {
77
+ const axis = new Set([...table.rowFieldIndices, ...table.colFieldIndices, ...table.pageFieldIndices]);
78
+ return Array.from({ length: fieldCount }, (_, i) => axis.has(i));
79
+ }
80
+
81
+ // =============================================================================
82
+ // pivotCacheRecords
83
+ // =============================================================================
84
+
85
+ /**
86
+ * One `<r>` per source row. A field carried on an axis (row/col/page) is
87
+ * "enumerated": its cells are written as `<x v="i"/>` indices into the cache
88
+ * field's shared items. Other fields (the measure, unused columns) are written
89
+ * inline (`<n>`, `<s>`, `<b>`, `<m/>`).
90
+ */
91
+ export function buildPivotCacheRecordsXml(
92
+ records: PivotSourceRecord[],
93
+ cache: ParsedPivotCache,
94
+ enumerate: boolean[],
95
+ ): string {
96
+ // index map per enumerated field: item key → position in shared items.
97
+ const indexMaps = cache.fields.map((f, i) => {
98
+ if (!enumerate[i]) return undefined;
99
+ const m = new Map<string, number>();
100
+ f.items.forEach((item, idx) => m.set(cacheItemKey(item), idx));
101
+ return m;
102
+ });
103
+
104
+ const rows = records.map((rec) => {
105
+ const cells = cache.fields.map((_f, i) => {
106
+ const item = sourceValueToItem(rec[i]);
107
+ const map = indexMaps[i];
108
+ if (map) {
109
+ const idx = map.get(cacheItemKey(item));
110
+ // A value absent from the shared items means the cache is stale (the
111
+ // source range changed after buildPivotTable) or inconsistent. Fail loud
112
+ // rather than emit a dangling index or silently bucket the value as blank.
113
+ if (idx === undefined) {
114
+ throw new Error(
115
+ `buildPivotCacheRecordsXml: value ${JSON.stringify(rec[i])} for field "${cache.fields[i].name}" is missing from its cached shared items`,
116
+ );
117
+ }
118
+ return `<x v="${idx}"/>`;
119
+ }
120
+ return inlineRecordCell(item);
121
+ });
122
+ return `<r>${cells.join("")}</r>`;
123
+ });
124
+
125
+ return (
126
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
127
+ `<pivotCacheRecords xmlns="${MAIN_NS}" xmlns:r="${REL_NS}" count="${records.length}">` +
128
+ rows.join("") +
129
+ `</pivotCacheRecords>`
130
+ );
131
+ }
132
+
133
+ function inlineRecordCell(item: PivotCacheItem): string {
134
+ switch (item.kind) {
135
+ case "missing":
136
+ return "<m/>";
137
+ case "number":
138
+ return `<n v="${item.value}"/>`;
139
+ case "boolean":
140
+ return `<b v="${item.value ? 1 : 0}"/>`;
141
+ case "date":
142
+ return `<d v="${escapeXml(item.value)}"/>`;
143
+ case "error":
144
+ return `<e v="${escapeXml(item.value)}"/>`;
145
+ case "string":
146
+ return `<s v="${escapeXml(item.value)}"/>`;
147
+ }
148
+ }
149
+
150
+ // =============================================================================
151
+ // pivotCacheDefinition
152
+ // =============================================================================
153
+
154
+ export function buildPivotCacheDefinitionXml(
155
+ cache: ParsedPivotCache,
156
+ enumerate: boolean[],
157
+ recordCount: number,
158
+ recordsRelId: string,
159
+ ): string {
160
+ if (cache.source.type !== "worksheet") {
161
+ throw new Error("buildPivotCacheDefinitionXml: only worksheet sources are supported");
162
+ }
163
+ const src = cache.source;
164
+ const fields = cache.fields
165
+ .map((f, i) => cacheFieldXml(f, enumerate[i]))
166
+ .join("");
167
+
168
+ return (
169
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
170
+ `<pivotCacheDefinition xmlns="${MAIN_NS}" xmlns:r="${REL_NS}" r:id="${recordsRelId}"` +
171
+ ` refreshOnLoad="1" refreshedBy="Lotics" createdVersion="6" refreshedVersion="6"` +
172
+ ` minRefreshableVersion="3" recordCount="${recordCount}">` +
173
+ `<cacheSource type="worksheet">` +
174
+ `<worksheetSource ref="${escapeXml(src.ref)}" sheet="${escapeXml(src.sheetName)}"/>` +
175
+ `</cacheSource>` +
176
+ `<cacheFields count="${cache.fields.length}">${fields}</cacheFields>` +
177
+ `</pivotCacheDefinition>`
178
+ );
179
+ }
180
+
181
+ function cacheFieldXml(field: PivotCacheField, enumerate: boolean): string {
182
+ const name = `name="${escapeXml(field.name)}" numFmtId="0"`;
183
+ if (!enumerate) {
184
+ // Measure / unused column: no enumerated items, just type hints. Excel
185
+ // refreshes the rest from the worksheet source.
186
+ const flags =
187
+ `containsBlank="${field.items.length === 0 ? 0 : 1}"` +
188
+ (field.containsNumber ? ` containsString="0" containsNumber="1"` : "");
189
+ return `<cacheField ${name}><sharedItems ${flags}/></cacheField>`;
190
+ }
191
+ const items = field.items.map(sharedItemXml).join("");
192
+ const hasString = field.items.some((i) => i.kind === "string");
193
+ const hasNumber = field.items.some((i) => i.kind === "number");
194
+ const hasBlank = field.items.some((i) => i.kind === "missing");
195
+ const attrs =
196
+ `count="${field.items.length}"` +
197
+ (hasBlank ? ` containsBlank="1"` : "") +
198
+ (hasNumber && !hasString ? ` containsString="0" containsNumber="1"` : "");
199
+ return `<cacheField ${name}><sharedItems ${attrs}>${items}</sharedItems></cacheField>`;
200
+ }
201
+
202
+ function sharedItemXml(item: PivotCacheItem): string {
203
+ switch (item.kind) {
204
+ case "missing":
205
+ return `<m/>`;
206
+ case "number":
207
+ return `<n v="${item.value}"/>`;
208
+ case "boolean":
209
+ return `<b v="${item.value ? 1 : 0}"/>`;
210
+ case "date":
211
+ return `<d v="${escapeXml(item.value)}"/>`;
212
+ case "error":
213
+ return `<e v="${escapeXml(item.value)}"/>`;
214
+ case "string":
215
+ return `<s v="${escapeXml(item.value)}"/>`;
216
+ }
217
+ }
218
+
219
+ // =============================================================================
220
+ // pivotTable
221
+ // =============================================================================
222
+
223
+ const SUBTOTAL_FN_TO_ENUM: Record<string, string> = {
224
+ sum: "sum",
225
+ count: "count",
226
+ countNums: "countNums",
227
+ average: "average",
228
+ min: "min",
229
+ max: "max",
230
+ product: "product",
231
+ };
232
+
233
+ /**
234
+ * Serialize the pivot table definition. The axis fields (row/col/page) carry a
235
+ * `<pivotField>` with an `<items>` list; the data and unused fields are emitted
236
+ * without items. Axis roles are read from `table`, so no enumerate hint is needed.
237
+ */
238
+ export function buildPivotTableXml(
239
+ table: ParsedPivotTable,
240
+ cache: ParsedPivotCache,
241
+ ): string {
242
+ const rowField = table.rowFieldIndices[0];
243
+ const colField = table.colFieldIndices[0];
244
+ if (rowField === undefined || colField === undefined) {
245
+ throw new Error("buildPivotTableXml: a row field and a column field are required");
246
+ }
247
+ const pageSet = new Set(table.pageFieldIndices);
248
+
249
+ const pivotFields = cache.fields
250
+ .map((f, i) => {
251
+ const onAxis =
252
+ i === rowField ? "axisRow" : i === colField ? "axisCol" : pageSet.has(i) ? "axisPage" : undefined;
253
+ if (onAxis) return axisPivotFieldXml(onAxis, f.items.length);
254
+ if (table.dataFields.some((d) => d.fieldIndex === i)) {
255
+ return `<pivotField dataField="1" showAll="0"/>`;
256
+ }
257
+ return `<pivotField showAll="0"/>`;
258
+ })
259
+ .join("");
260
+
261
+ const rowCount = cache.fields[rowField].items.length;
262
+ const colCount = cache.fields[colField].items.length;
263
+ // A bottom grand-total ROW is a colGrandTotals; a right grand-total COLUMN is
264
+ // a rowGrandTotals (OOXML's naming is "grand totals FOR rows/columns").
265
+ const rowItems = axisItemsXml("rowItems", rowCount, table.display.colGrandTotals);
266
+ const colItems = axisItemsXml("colItems", colCount, table.display.rowGrandTotals);
267
+
268
+ const pageFieldsXml = table.pageFieldIndices.length
269
+ ? `<pageFields count="${table.pageFieldIndices.length}">` +
270
+ table.pageFieldIndices
271
+ .map((fld) => {
272
+ const sel = table.fields[fld]?.selectedPageItem;
273
+ const item = sel === null || sel === undefined ? "" : ` item="${sel}"`;
274
+ return `<pageField fld="${fld}"${item} hier="-1"/>`;
275
+ })
276
+ .join("") +
277
+ `</pageFields>`
278
+ : "";
279
+
280
+ const dataFieldsXml =
281
+ `<dataFields count="${table.dataFields.length}">` +
282
+ table.dataFields
283
+ .map((d) => {
284
+ const fn = SUBTOTAL_FN_TO_ENUM[d.subtotal] ?? "sum";
285
+ const sub = fn === "sum" ? "" : ` subtotal="${fn}"`;
286
+ const numFmt = d.numFmt ? ` numFmtId="${escapeXml(d.numFmt)}"` : "";
287
+ return `<dataField name="${escapeXml(d.name)}" fld="${d.fieldIndex}"${sub} baseField="0" baseItem="0"${numFmt}/>`;
288
+ })
289
+ .join("") +
290
+ `</dataFields>`;
291
+
292
+ const style =
293
+ `<pivotTableStyleInfo name="${escapeXml(table.styleName ?? "PivotStyleLight16")}"` +
294
+ ` showRowHeaders="1" showColHeaders="1"` +
295
+ ` showRowStripes="${table.display.showRowStripes ? 1 : 0}"` +
296
+ ` showColStripes="${table.display.showColStripes ? 1 : 0}" showLastColumn="1"/>`;
297
+
298
+ return (
299
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
300
+ `<pivotTableDefinition xmlns="${MAIN_NS}" xmlns:r="${REL_NS}"` +
301
+ ` name="${escapeXml(table.name)}" cacheId="${table.cacheId}"` +
302
+ ` applyNumberFormats="0" applyBorderFormats="0" applyFontFormats="0"` +
303
+ ` applyPatternFormats="0" applyAlignmentFormats="0" applyWidthHeightFormats="1"` +
304
+ ` dataCaption="Values" updatedVersion="6" minRefreshableVersion="3"` +
305
+ ` useAutoFormatting="1" itemPrintTitles="1" createdVersion="6" indent="0"` +
306
+ ` outline="1" outlineData="1" multipleFieldFilters="0"` +
307
+ ` rowGrandTotals="${table.display.rowGrandTotals ? 1 : 0}"` +
308
+ ` colGrandTotals="${table.display.colGrandTotals ? 1 : 0}">` +
309
+ // ref spans the body grid only; page-filter rows (if any) are laid out by
310
+ // Excel above it on refresh, so the location stays body-relative + consistent.
311
+ `<location ref="${escapeXml(table.ref)}" firstHeaderRow="${table.firstHeaderRow}"` +
312
+ ` firstDataRow="${table.firstDataRow}" firstDataCol="${table.firstDataCol}"/>` +
313
+ `<pivotFields count="${cache.fields.length}">${pivotFields}</pivotFields>` +
314
+ `<rowFields count="1"><field x="${rowField}"/></rowFields>` +
315
+ rowItems +
316
+ `<colFields count="1"><field x="${colField}"/></colFields>` +
317
+ colItems +
318
+ pageFieldsXml +
319
+ dataFieldsXml +
320
+ style +
321
+ `</pivotTableDefinition>`
322
+ );
323
+ }
324
+
325
+ /** A row/col axis field: items listed in shared-item order + a subtotal slot. */
326
+ function axisPivotFieldXml(axis: string, itemCount: number): string {
327
+ const items: string[] = [];
328
+ for (let i = 0; i < itemCount; i++) items.push(`<item x="${i}"/>`);
329
+ items.push(`<item t="default"/>`);
330
+ return `<pivotField axis="${axis}" showAll="0"><items count="${items.length}">${items.join("")}</items></pivotField>`;
331
+ }
332
+
333
+ /** `<rowItems>`/`<colItems>`: one `<i>` per shared item (display order = shared
334
+ * order, since the cache is pre-sorted) plus a trailing grand-total `<i>`. */
335
+ function axisItemsXml(element: "rowItems" | "colItems", itemCount: number, grandTotal: boolean): string {
336
+ const items: string[] = [];
337
+ for (let i = 0; i < itemCount; i++) items.push(`<i><x v="${i}"/></i>`);
338
+ if (grandTotal) items.push(`<i t="grand"><x/></i>`);
339
+ return `<${element} count="${items.length}">${items.join("")}</${element}>`;
340
+ }
@@ -12,8 +12,15 @@ import type { WorkbookModel, SheetModel, CellModel } from "./workbook_model";
12
12
  import type { CellStyle, RichTextPart, RichTextFont } from "./types";
13
13
  import type { ParsedChart, ParsedTable, ParsedDrawing, ChartType, ChartSeries } from "./ooxml_chart_types";
14
14
  import type { ParsedConditionalFormat, ParsedCfStyle } from "./excel_cf_types";
15
- import { rowColToRef, refToRowCol, colNumToLetters } from "./excel_utils";
15
+ import { rowColToRef, refToRowCol, colNumToLetters, escapeXml } from "./excel_utils";
16
16
  import { hexToArgb, hexToOoxmlRgb } from "./ooxml_color";
17
+ import { readSourceData } from "./pivot_model";
18
+ import {
19
+ buildPivotCacheDefinitionXml,
20
+ buildPivotCacheRecordsXml,
21
+ buildPivotTableXml,
22
+ enumerateFlags,
23
+ } from "./pivot_writer";
17
24
 
18
25
  // =============================================================================
19
26
  // Pivot round-trip helpers
@@ -183,6 +190,78 @@ function pivotContentTypeFor(path: string): string | undefined {
183
190
  return undefined;
184
191
  }
185
192
 
193
+ const REL_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
194
+
195
+ function relsXml(rels: { id: string; type: string; target: string }[]): string {
196
+ return (
197
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
198
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n` +
199
+ rels.map((r) => `<Relationship Id="${r.id}" Type="${r.type}" Target="${escapeXml(r.target)}"/>`).join("\n") +
200
+ `\n</Relationships>`
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Serialize every AUTHORED pivot (built via `buildPivotTable`) into fresh OOXML
206
+ * parts written straight into `entries`, and return the cross-reference snapshot
207
+ * the regenerated workbook/sheet rels + content-types consume (same shape the
208
+ * round-trip path produces). Parsed/round-tripped pivots are skipped — they pass
209
+ * through verbatim. `startFileIndex` is the first pivotTableN/cacheN suffix,
210
+ * past any round-tripped pivot files, so paths never collide.
211
+ */
212
+ function emitAuthoredPivots(
213
+ workbook: WorkbookModel,
214
+ entries: Record<string, Uint8Array>,
215
+ startFileIndex: number,
216
+ ): PivotRoundTripInfo {
217
+ const info: PivotRoundTripInfo = {
218
+ cachesByWorkbook: new Map(),
219
+ pivotTablesBySheetIndex: new Map(),
220
+ pivotXmlPaths: [],
221
+ };
222
+ let n = startFileIndex;
223
+ for (let i = 0; i < workbook.sheets.length; i++) {
224
+ for (const model of workbook.sheets[i].pivotTables) {
225
+ if (!model.authored) continue;
226
+ const idx = n++;
227
+ const enumerate = enumerateFlags(model.config, model.cache.fields.length);
228
+ const records = readSourceData(workbook, model.cache)?.records ?? [];
229
+
230
+ const cacheDefPath = `xl/pivotCache/pivotCacheDefinition${idx}.xml`;
231
+ const recordsPath = `xl/pivotCache/pivotCacheRecords${idx}.xml`;
232
+ const tablePath = `xl/pivotTables/pivotTable${idx}.xml`;
233
+
234
+ entries[cacheDefPath] = strToU8(buildPivotCacheDefinitionXml(model.cache, enumerate, records.length, "rId1"));
235
+ entries[`xl/pivotCache/_rels/pivotCacheDefinition${idx}.xml.rels`] = strToU8(
236
+ relsXml([{ id: "rId1", type: `${REL_BASE}/pivotCacheRecords`, target: `pivotCacheRecords${idx}.xml` }]),
237
+ );
238
+ entries[recordsPath] = strToU8(buildPivotCacheRecordsXml(records, model.cache, enumerate));
239
+ entries[tablePath] = strToU8(buildPivotTableXml(model.config, model.cache));
240
+ entries[`xl/pivotTables/_rels/pivotTable${idx}.xml.rels`] = strToU8(
241
+ relsXml([{ id: "rId1", type: `${REL_BASE}/pivotCacheDefinition`, target: `../pivotCache/pivotCacheDefinition${idx}.xml` }]),
242
+ );
243
+
244
+ info.cachesByWorkbook.set(model.cache.id, `pivotCache/pivotCacheDefinition${idx}.xml`);
245
+ const list = info.pivotTablesBySheetIndex.get(i) ?? [];
246
+ list.push(`../pivotTables/pivotTable${idx}.xml`);
247
+ info.pivotTablesBySheetIndex.set(i, list);
248
+ info.pivotXmlPaths.push(tablePath, cacheDefPath, recordsPath);
249
+ }
250
+ }
251
+ return info;
252
+ }
253
+
254
+ function mergePivotInfo(a: PivotRoundTripInfo, b: PivotRoundTripInfo): PivotRoundTripInfo {
255
+ const cachesByWorkbook = new Map(a.cachesByWorkbook);
256
+ for (const [k, v] of b.cachesByWorkbook) cachesByWorkbook.set(k, v);
257
+ const pivotTablesBySheetIndex = new Map<number, string[]>();
258
+ for (const [k, v] of a.pivotTablesBySheetIndex) pivotTablesBySheetIndex.set(k, [...v]);
259
+ for (const [k, v] of b.pivotTablesBySheetIndex) {
260
+ pivotTablesBySheetIndex.set(k, [...(pivotTablesBySheetIndex.get(k) ?? []), ...v]);
261
+ }
262
+ return { cachesByWorkbook, pivotTablesBySheetIndex, pivotXmlPaths: [...a.pivotXmlPaths, ...b.pivotXmlPaths] };
263
+ }
264
+
186
265
  // =============================================================================
187
266
  // Main export function
188
267
  // =============================================================================
@@ -244,11 +323,14 @@ export function exportWorkbook(
244
323
  : buildStylesXml(workbook);
245
324
  if (!stylesPassthrough) entries["xl/styles.xml"] = strToU8(stylesResult.xml);
246
325
 
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);
326
+ // Pivot cross-references. Two sources feed the regenerated workbook/sheet rels
327
+ // + content-types: pivots ROUND-TRIPPED from `originalZip` (their xml passes
328
+ // through verbatim) and pivots AUTHORED in the model (serialized fresh here,
329
+ // straight into `entries`).
330
+ const roundTripInfo = extractPivotRoundTripInfo(originalZip);
331
+ const authoredStartIndex =
332
+ roundTripInfo.pivotXmlPaths.filter((p) => p.startsWith("xl/pivotTables/")).length + 1;
333
+ const pivotInfo = mergePivotInfo(roundTripInfo, emitAuthoredPivots(workbook, entries, authoredStartIndex));
252
334
 
253
335
  // Build workbook.xml
254
336
  entries["xl/workbook.xml"] = strToU8(buildWorkbookXml(workbook, pivotInfo));
@@ -1539,11 +1621,3 @@ function dataUrlToBytes(dataUrl: string): Uint8Array | null {
1539
1621
  // Helpers
1540
1622
  // =============================================================================
1541
1623
 
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
- }