@casualoffice/sheets 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +200 -0
  2. package/dist/embed/embed-runtime.js +537 -0
  3. package/dist/embed/embed.html +29 -0
  4. package/dist/embed/parser.worker.js +48474 -0
  5. package/dist/embed.cjs +225 -0
  6. package/dist/embed.cjs.map +1 -0
  7. package/dist/embed.d.cts +100 -0
  8. package/dist/embed.d.ts +100 -0
  9. package/dist/embed.js +204 -0
  10. package/dist/embed.js.map +1 -0
  11. package/dist/index.cjs +1549 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +9 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.js +1530 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/parser.worker.cjs +48469 -0
  18. package/dist/parser.worker.cjs.map +1 -0
  19. package/dist/parser.worker.js +48474 -0
  20. package/dist/parser.worker.js.map +1 -0
  21. package/dist/protocol--KyBQUjU.d.cts +171 -0
  22. package/dist/protocol-cEzy7S0i.d.ts +171 -0
  23. package/dist/sheets.cjs +677 -0
  24. package/dist/sheets.cjs.map +1 -0
  25. package/dist/sheets.d.cts +177 -0
  26. package/dist/sheets.d.ts +177 -0
  27. package/dist/sheets.js +658 -0
  28. package/dist/sheets.js.map +1 -0
  29. package/dist/signing.cjs +706 -0
  30. package/dist/signing.cjs.map +1 -0
  31. package/dist/signing.d.cts +141 -0
  32. package/dist/signing.d.ts +141 -0
  33. package/dist/signing.js +683 -0
  34. package/dist/signing.js.map +1 -0
  35. package/dist/styles.cjs +10 -0
  36. package/dist/styles.cjs.map +1 -0
  37. package/dist/styles.d.cts +2 -0
  38. package/dist/styles.d.ts +2 -0
  39. package/dist/styles.js +8 -0
  40. package/dist/styles.js.map +1 -0
  41. package/dist/types-s_O0u6Cg.d.cts +90 -0
  42. package/dist/types-s_O0u6Cg.d.ts +90 -0
  43. package/dist/univer.cjs +220 -0
  44. package/dist/univer.cjs.map +1 -0
  45. package/dist/univer.d.cts +60 -0
  46. package/dist/univer.d.ts +60 -0
  47. package/dist/univer.js +187 -0
  48. package/dist/univer.js.map +1 -0
  49. package/dist/xlsx.cjs +3388 -0
  50. package/dist/xlsx.cjs.map +1 -0
  51. package/dist/xlsx.d.cts +383 -0
  52. package/dist/xlsx.d.ts +383 -0
  53. package/dist/xlsx.js +3383 -0
  54. package/dist/xlsx.js.map +1 -0
  55. package/package.json +293 -0
  56. package/src/embed/EmbedHostTransport.ts +226 -0
  57. package/src/embed/EmbedTransport.ts +323 -0
  58. package/src/embed/EmbedTransport.unit.test.ts +161 -0
  59. package/src/embed/index.ts +40 -0
  60. package/src/embed/protocol.ts +258 -0
  61. package/src/embed-runtime/embed.html +29 -0
  62. package/src/embed-runtime/index.tsx +440 -0
  63. package/src/index.ts +16 -0
  64. package/src/sheets/CasualSheets.tsx +319 -0
  65. package/src/sheets/CasualSheetsIframe.tsx +220 -0
  66. package/src/sheets/api.ts +108 -0
  67. package/src/sheets/index.ts +11 -0
  68. package/src/signing/SigningPane.tsx +374 -0
  69. package/src/signing/SigningProvider.tsx +126 -0
  70. package/src/signing/captures.tsx +316 -0
  71. package/src/signing/controller.ts +151 -0
  72. package/src/signing/controller.unit.test.ts +133 -0
  73. package/src/signing/index.ts +44 -0
  74. package/src/signing/types.ts +89 -0
  75. package/src/styles.ts +16 -0
  76. package/src/univer/index.ts +17 -0
  77. package/src/univer/lazy-plugins.ts +280 -0
  78. package/src/xlsx/_perf.ts +14 -0
  79. package/src/xlsx/_snapshot-constants.ts +15 -0
  80. package/src/xlsx/comments-resource.ts +209 -0
  81. package/src/xlsx/constants.ts +9 -0
  82. package/src/xlsx/data-validation-resource.ts +219 -0
  83. package/src/xlsx/import.ts +35 -0
  84. package/src/xlsx/index.ts +40 -0
  85. package/src/xlsx/page-setup-resource.ts +205 -0
  86. package/src/xlsx/parse-impl.ts +418 -0
  87. package/src/xlsx/parse-in-worker.ts +82 -0
  88. package/src/xlsx/parser.worker.ts +39 -0
  89. package/src/xlsx/passthrough-resource.ts +175 -0
  90. package/src/xlsx/pivot-passthrough.ts +359 -0
  91. package/src/xlsx/style-mapping.ts +171 -0
  92. package/src/xlsx/tables-resource.ts +211 -0
@@ -0,0 +1,175 @@
1
+ import JSZip from 'jszip';
2
+ import type { IWorkbookData } from '@univerjs/core';
3
+ import {
4
+ applyPivotsToZip,
5
+ capturePivotsFromBuffer,
6
+ type PivotPassthroughPayload,
7
+ } from './pivot-passthrough';
8
+
9
+ /**
10
+ * Sidecar resource that carries raw OOXML parts ExcelJS silently drops.
11
+ *
12
+ * Today we passthrough:
13
+ * - `xl/vbaProject.bin` — `.xlsm` macros round-trip byte-equal.
14
+ * - `xl/pivotCaches/**` + `xl/pivotTables/**` — pivot definitions
15
+ * survive a round-trip so Excel still sees the file as having
16
+ * pivot tables (the materialised cells already survive via the
17
+ * normal cell pipeline; this re-instates the metadata Excel
18
+ * needs to render filter dropdowns, refresh, etc).
19
+ *
20
+ * We never execute VBA. The pivot OOXML is treated as opaque bytes —
21
+ * Univer doesn't render pivots from the OOXML, only the cells; this
22
+ * preserves the metadata for Excel's benefit.
23
+ */
24
+ export const XLSX_PASSTHROUGH_RESOURCE = '__casual_sheets_xlsx_passthrough__';
25
+
26
+ export type XlsxPassthroughPayload = {
27
+ /** base64-encoded contents of xl/vbaProject.bin */
28
+ vba?: { binBase64: string };
29
+ /** raw OOXML pivot machinery — see pivot-passthrough.ts */
30
+ pivots?: PivotPassthroughPayload;
31
+ };
32
+
33
+ const VBA_REL_TYPE =
34
+ 'http://schemas.microsoft.com/office/2006/relationships/vbaProject';
35
+ const VBA_CONTENT_TYPE = 'application/vnd.ms-office.vbaProject';
36
+
37
+ export const XLSX_MIME =
38
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
39
+ export const XLSM_MIME = 'application/vnd.ms-excel.sheet.macroEnabled.12';
40
+
41
+ export function mimeForPassthrough(
42
+ payload: XlsxPassthroughPayload | undefined,
43
+ ): string {
44
+ return payload?.vba ? XLSM_MIME : XLSX_MIME;
45
+ }
46
+
47
+ export function extensionForPassthrough(
48
+ payload: XlsxPassthroughPayload | undefined,
49
+ ): 'xlsx' | 'xlsm' {
50
+ return payload?.vba ? 'xlsm' : 'xlsx';
51
+ }
52
+
53
+ export async function capturePassthroughFromBuffer(
54
+ buffer: ArrayBuffer,
55
+ ): Promise<XlsxPassthroughPayload | undefined> {
56
+ // VBA — single binary part. Cheap probe; bail early if neither
57
+ // VBA nor pivots are present.
58
+ let vba: XlsxPassthroughPayload['vba'];
59
+ try {
60
+ const zip = await JSZip.loadAsync(buffer);
61
+ const vbaFile = zip.file('xl/vbaProject.bin');
62
+ if (vbaFile) {
63
+ vba = { binBase64: await vbaFile.async('base64') };
64
+ }
65
+ } catch {
66
+ return undefined;
67
+ }
68
+
69
+ // Pivots — re-opens the buffer internally. Could be optimised to a
70
+ // single zip read but the second open is ~tens of ms even for big
71
+ // files and the symmetry with VBA is more important.
72
+ const pivots = await capturePivotsFromBuffer(buffer);
73
+
74
+ if (!vba && !pivots) return undefined;
75
+ return { vba, pivots };
76
+ }
77
+
78
+ export function mergePassthroughIntoResources(
79
+ resources: IWorkbookData['resources'],
80
+ payload: XlsxPassthroughPayload | undefined,
81
+ ): IWorkbookData['resources'] {
82
+ if (!payload) return resources;
83
+ const filtered = (resources ?? []).filter(
84
+ (r) => r.name !== XLSX_PASSTHROUGH_RESOURCE,
85
+ );
86
+ return [
87
+ ...filtered,
88
+ { name: XLSX_PASSTHROUGH_RESOURCE, data: JSON.stringify(payload) },
89
+ ];
90
+ }
91
+
92
+ export function readPassthroughFromSnapshot(
93
+ data: IWorkbookData,
94
+ ): XlsxPassthroughPayload | undefined {
95
+ const entry = data.resources?.find(
96
+ (r) => r.name === XLSX_PASSTHROUGH_RESOURCE,
97
+ );
98
+ if (!entry?.data) return undefined;
99
+ try {
100
+ return JSON.parse(entry.data) as XlsxPassthroughPayload;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ const REL_TYPE_REGEX_ESCAPE = (s: string) =>
107
+ s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
+
109
+ /**
110
+ * Re-inject every captured OOXML payload into the ExcelJS-written
111
+ * buffer in one pass. VBA + pivots share a single JSZip session so
112
+ * the [Content_Types].xml + workbook.xml.rels patches compose cleanly
113
+ * (each patch sees the previous step's writes).
114
+ *
115
+ * The input buffer is read but not mutated; a fresh ArrayBuffer is
116
+ * returned. No-op when the payload is empty.
117
+ */
118
+ export async function applyPassthroughToXlsxBuffer(
119
+ excelJsBuffer: ArrayBuffer | Uint8Array,
120
+ payload: XlsxPassthroughPayload | undefined,
121
+ ): Promise<ArrayBuffer> {
122
+ if (!payload?.vba && !payload?.pivots) {
123
+ if (excelJsBuffer instanceof ArrayBuffer) return excelJsBuffer;
124
+ return excelJsBuffer.buffer.slice(
125
+ excelJsBuffer.byteOffset,
126
+ excelJsBuffer.byteOffset + excelJsBuffer.byteLength,
127
+ ) as ArrayBuffer;
128
+ }
129
+
130
+ const zip = await JSZip.loadAsync(excelJsBuffer);
131
+
132
+ if (payload.vba) await applyVbaToZip(zip, payload.vba);
133
+ if (payload.pivots) await applyPivotsToZip(zip, payload.pivots);
134
+
135
+ return zip.generateAsync({ type: 'arraybuffer' });
136
+ }
137
+
138
+ async function applyVbaToZip(
139
+ zip: JSZip,
140
+ vba: NonNullable<XlsxPassthroughPayload['vba']>,
141
+ ): Promise<void> {
142
+ zip.file('xl/vbaProject.bin', vba.binBase64, { base64: true });
143
+
144
+ // [Content_Types].xml — add Override for /xl/vbaProject.bin if missing.
145
+ const ctEntry = zip.file('[Content_Types].xml');
146
+ if (ctEntry) {
147
+ let ct = await ctEntry.async('string');
148
+ if (!/PartName="\/xl\/vbaProject\.bin"/i.test(ct)) {
149
+ const override = `<Override PartName="/xl/vbaProject.bin" ContentType="${VBA_CONTENT_TYPE}"/>`;
150
+ ct = ct.replace('</Types>', `${override}</Types>`);
151
+ zip.file('[Content_Types].xml', ct);
152
+ }
153
+ }
154
+
155
+ // xl/_rels/workbook.xml.rels — append a vbaProject relationship
156
+ // with the next-free rId. ExcelJS-written rels already declares
157
+ // sheet / styles / theme / sharedStrings; just need a unique id.
158
+ const relsPath = 'xl/_rels/workbook.xml.rels';
159
+ const relsEntry = zip.file(relsPath);
160
+ if (relsEntry) {
161
+ let rels = await relsEntry.async('string');
162
+ const vbaRelTypeRegex = new RegExp(
163
+ `Type="${REL_TYPE_REGEX_ESCAPE(VBA_REL_TYPE)}"`,
164
+ );
165
+ if (!vbaRelTypeRegex.test(rels)) {
166
+ const used = new Set<number>();
167
+ for (const m of rels.matchAll(/Id="rId(\d+)"/g)) used.add(Number(m[1]));
168
+ let next = 1;
169
+ while (used.has(next)) next++;
170
+ const rel = `<Relationship Id="rId${next}" Type="${VBA_REL_TYPE}" Target="vbaProject.bin"/>`;
171
+ rels = rels.replace('</Relationships>', `${rel}</Relationships>`);
172
+ zip.file(relsPath, rels);
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,359 @@
1
+ import JSZip from 'jszip';
2
+
3
+ /**
4
+ * Complex pivot cache + table passthrough.
5
+ *
6
+ * ExcelJS reads xlsx at the worksheet API level and never touches the
7
+ * pivot OOXML parts. On a naive open→save loop, every pivot
8
+ * definition gets silently dropped — the materialised cells survive,
9
+ * but Excel sees the file as flat data on re-open.
10
+ *
11
+ * This module captures the raw OOXML parts at parse time and re-
12
+ * injects them at export. Compared to VBA passthrough (single binary
13
+ * with one rel + one Content_Type entry), pivots require:
14
+ *
15
+ * 1. Multiple parts: `xl/pivotCaches/pivotCacheDefinition{N}.xml`,
16
+ * `xl/pivotCaches/pivotCacheRecords{N}.xml`,
17
+ * `xl/pivotTables/pivotTable{N}.xml`, plus each part's own
18
+ * `.rels` (which we ship verbatim — those rId spaces are local).
19
+ * 2. Rel renumbering across `xl/_rels/workbook.xml.rels` AND every
20
+ * sheet's `xl/worksheets/_rels/sheet{N}.xml.rels`. Original rIds
21
+ * would collide with ExcelJS-assigned ones, so we map old → new
22
+ * at inject time.
23
+ * 3. Surgery on the ExcelJS-regenerated `xl/workbook.xml` to inject
24
+ * a `<pivotCaches>` element with the REMAPPED rIds, since
25
+ * ExcelJS regenerates that file from scratch and drops the
26
+ * element.
27
+ * 4. Multiple `Override` entries in `[Content_Types].xml` for the
28
+ * three pivot ContentTypes.
29
+ *
30
+ * Sheet matching at inject time: the original capture records pivots
31
+ * by **sheet name** (not by sheet rId), because ExcelJS may reorder
32
+ * sheet files. At inject we look up the new sheet path via the
33
+ * regenerated workbook.xml `<sheets>` block, then write into that
34
+ * sheet's `.rels`.
35
+ *
36
+ * Constraints/assumptions:
37
+ * - Univer renders the materialised cells from sheet.xml; the
38
+ * pivot table itself is dropped from Univer's runtime model
39
+ * today. Edits to the underlying data don't propagate back into
40
+ * the pivot cache. This passthrough only preserves the OOXML so
41
+ * Excel re-recognises the file as having pivots — not full
42
+ * interactive pivot editing in our app.
43
+ * - We never modify the pivot parts' own contents.
44
+ * - We don't try to reconcile the cached pivot data with the live
45
+ * cells if the user edits the source range. Excel's "Refresh"
46
+ * handles that on its end.
47
+ */
48
+
49
+ /** Three OOXML namespaces that identify pivot parts in `*.rels`. */
50
+ const REL_TYPE_PIVOT_CACHE_DEF =
51
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition';
52
+ const REL_TYPE_PIVOT_TABLE =
53
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable';
54
+
55
+ const CT_PIVOT_CACHE_DEF =
56
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml';
57
+ const CT_PIVOT_CACHE_REC =
58
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml';
59
+ const CT_PIVOT_TABLE =
60
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml';
61
+
62
+ export type PivotPassthroughPayload = {
63
+ /** Every captured part — pivotCaches/**, pivotTables/**, and any
64
+ * per-part `.rels` files — keyed by zip path; base64 contents. */
65
+ parts: Record<string, string>;
66
+ /** The raw `<pivotCaches>…</pivotCaches>` block extracted from
67
+ * `xl/workbook.xml` at parse time. The `r:id` refs inside get
68
+ * remapped at inject time. */
69
+ workbookPivotCachesXml: string;
70
+ /** Workbook-level pivotCacheDefinition relationships. `origId` is
71
+ * the captured rId — used to remap the `r:id` references inside
72
+ * `workbookPivotCachesXml`. */
73
+ workbookCacheRels: Array<{ origId: string; target: string }>;
74
+ /** Pivot tables, grouped by the sheet they belong to. Sheets are
75
+ * identified by NAME (not rId) because ExcelJS may reorder files. */
76
+ perSheet: Array<{
77
+ sheetName: string;
78
+ /** sheet's pivotTable rels. `origId` is the captured rId in the
79
+ * ORIGINAL sheet rels file; we assign fresh rIds at inject time. */
80
+ pivotTableRels: Array<{ origId: string; target: string }>;
81
+ }>;
82
+ };
83
+
84
+ /** Best-effort namespace-tolerant `r:id="..."` extractor. */
85
+ function extractRid(xml: string): string | null {
86
+ const m = xml.match(/r:id="([^"]+)"/i) ?? xml.match(/r:Id="([^"]+)"/i);
87
+ return m ? m[1] : null;
88
+ }
89
+
90
+ /** Strip the wrapping <Relationships> shell — used so we can append
91
+ * captured rels without doubling up. */
92
+ function parseRelationships(xml: string): Array<{ id: string; type: string; target: string }> {
93
+ const out: Array<{ id: string; type: string; target: string }> = [];
94
+ for (const m of xml.matchAll(/<Relationship\b[^>]*\/>/g)) {
95
+ const tag = m[0];
96
+ const id = tag.match(/Id="([^"]+)"/)?.[1];
97
+ const type = tag.match(/Type="([^"]+)"/)?.[1];
98
+ const target = tag.match(/Target="([^"]+)"/)?.[1];
99
+ if (id && type && target) out.push({ id, type, target });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Closure that returns the next free `rIdN` string each call, given
105
+ * a Set tracking which numbers are already used. The set is mutated
106
+ * in place so successive calls don't collide. */
107
+ function nextRidIssuer(used: Set<number>): () => string {
108
+ return () => {
109
+ let n = 1;
110
+ while (used.has(n)) n++;
111
+ used.add(n);
112
+ return `rId${n}`;
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Walk the input buffer (the raw uploaded xlsx) and pull out every
118
+ * piece of pivot machinery. Returns `undefined` when no pivot parts
119
+ * are present — callers should skip the rest of the pipeline in that
120
+ * case to avoid bloating snapshots with empty payloads.
121
+ */
122
+ export async function capturePivotsFromBuffer(
123
+ buffer: ArrayBuffer,
124
+ ): Promise<PivotPassthroughPayload | undefined> {
125
+ let zip: JSZip;
126
+ try {
127
+ zip = await JSZip.loadAsync(buffer);
128
+ } catch {
129
+ return undefined;
130
+ }
131
+
132
+ // Quick presence check — is there anything pivot-shaped at all?
133
+ const hasPivots = Object.keys(zip.files).some(
134
+ (p) => p.startsWith('xl/pivotCaches/') || p.startsWith('xl/pivotTables/'),
135
+ );
136
+ if (!hasPivots) return undefined;
137
+
138
+ // 1. Capture every pivot-cache and pivot-table part + their own .rels.
139
+ const parts: Record<string, string> = {};
140
+ for (const [path, file] of Object.entries(zip.files)) {
141
+ if (file.dir) continue;
142
+ if (
143
+ path.startsWith('xl/pivotCaches/') ||
144
+ path.startsWith('xl/pivotTables/')
145
+ ) {
146
+ parts[path] = await file.async('base64');
147
+ }
148
+ }
149
+
150
+ // 2. Extract <pivotCaches>...</pivotCaches> from xl/workbook.xml.
151
+ const workbookXmlEntry = zip.file('xl/workbook.xml');
152
+ if (!workbookXmlEntry) return undefined;
153
+ const workbookXml = await workbookXmlEntry.async('string');
154
+ const pivotCachesMatch = workbookXml.match(/<pivotCaches\b[^>]*>[\s\S]*?<\/pivotCaches>/);
155
+ if (!pivotCachesMatch) return undefined;
156
+ const workbookPivotCachesXml = pivotCachesMatch[0];
157
+
158
+ // 3. Workbook-level pivotCacheDefinition rels.
159
+ const workbookRelsEntry = zip.file('xl/_rels/workbook.xml.rels');
160
+ if (!workbookRelsEntry) return undefined;
161
+ const workbookRelsXml = await workbookRelsEntry.async('string');
162
+ const workbookCacheRels = parseRelationships(workbookRelsXml)
163
+ .filter((r) => r.type === REL_TYPE_PIVOT_CACHE_DEF)
164
+ .map((r) => ({ origId: r.id, target: r.target }));
165
+
166
+ // 4. Per-sheet pivotTable rels. Need to map sheet rels file → sheet
167
+ // name via workbook.xml + workbook.xml.rels.
168
+ // workbook.xml: <sheet name="..." r:id="rIdN"/>
169
+ // workbook.xml.rels: rIdN → Target="worksheets/sheetN.xml"
170
+ // sheetN.xml.rels: pivot table rels live here.
171
+ const sheetEntries: Array<{ name: string; rId: string; targetPath: string }> = [];
172
+ for (const m of workbookXml.matchAll(/<sheet\b[^>]*\/>/g)) {
173
+ const tag = m[0];
174
+ const name = tag.match(/name="([^"]+)"/)?.[1];
175
+ const rId = tag.match(/r:id="([^"]+)"/i)?.[1] ?? tag.match(/r:Id="([^"]+)"/)?.[1];
176
+ if (!name || !rId) continue;
177
+ const rel = parseRelationships(workbookRelsXml).find((r) => r.id === rId);
178
+ if (!rel) continue;
179
+ sheetEntries.push({ name, rId, targetPath: rel.target });
180
+ }
181
+
182
+ const perSheet: PivotPassthroughPayload['perSheet'] = [];
183
+ for (const sheet of sheetEntries) {
184
+ // target like "worksheets/sheet1.xml" — rels live at
185
+ // "xl/worksheets/_rels/sheet1.xml.rels".
186
+ const fileName = sheet.targetPath.split('/').pop()!;
187
+ const sheetRelsPath = `xl/worksheets/_rels/${fileName}.rels`;
188
+ const sheetRelsEntry = zip.file(sheetRelsPath);
189
+ if (!sheetRelsEntry) continue;
190
+ const sheetRelsXml = await sheetRelsEntry.async('string');
191
+ const pivotTableRels = parseRelationships(sheetRelsXml)
192
+ .filter((r) => r.type === REL_TYPE_PIVOT_TABLE)
193
+ .map((r) => ({ origId: r.id, target: r.target }));
194
+ if (pivotTableRels.length > 0) {
195
+ perSheet.push({ sheetName: sheet.name, pivotTableRels });
196
+ }
197
+ }
198
+
199
+ // Only emit a payload when we actually have something to round-trip.
200
+ if (
201
+ Object.keys(parts).length === 0 ||
202
+ workbookCacheRels.length === 0
203
+ ) {
204
+ return undefined;
205
+ }
206
+
207
+ return {
208
+ parts,
209
+ workbookPivotCachesXml,
210
+ workbookCacheRels,
211
+ perSheet,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Re-inject every captured pivot piece into the ExcelJS-written xlsx
217
+ * buffer. The input buffer is read but not mutated; the same JSZip
218
+ * instance is finalised by the caller (so caller can compose multiple
219
+ * passthrough steps).
220
+ *
221
+ * Mutates the passed zip in-place. Caller does the final
222
+ * generateAsync. This shape lets `passthrough-resource.ts` compose
223
+ * VBA and pivot passthrough into one final zip write.
224
+ */
225
+ export async function applyPivotsToZip(
226
+ zip: JSZip,
227
+ payload: PivotPassthroughPayload,
228
+ ): Promise<void> {
229
+ // 1. Drop every captured part back at its original path.
230
+ for (const [path, base64] of Object.entries(payload.parts)) {
231
+ zip.file(path, base64, { base64: true });
232
+ }
233
+
234
+ // 2. Patch [Content_Types].xml — add Overrides for any pivot part
235
+ // that doesn't already have one. We pick the ContentType from
236
+ // the path: pivotCacheDefinition / pivotCacheRecords / pivotTable.
237
+ const ctEntry = zip.file('[Content_Types].xml');
238
+ if (ctEntry) {
239
+ let ct = await ctEntry.async('string');
240
+ const inserts: string[] = [];
241
+ for (const path of Object.keys(payload.parts)) {
242
+ // Skip .rels files — those don't need ContentType Overrides
243
+ // (the default rels ContentType in Defaults covers them).
244
+ if (path.endsWith('.rels')) continue;
245
+ const partName = `/${path}`;
246
+ if (ct.includes(`PartName="${partName}"`)) continue;
247
+ let contentType: string;
248
+ if (/pivotCacheDefinition\d+\.xml$/.test(path)) contentType = CT_PIVOT_CACHE_DEF;
249
+ else if (/pivotCacheRecords\d+\.xml$/.test(path)) contentType = CT_PIVOT_CACHE_REC;
250
+ else if (/pivotTable\d+\.xml$/.test(path)) contentType = CT_PIVOT_TABLE;
251
+ else continue;
252
+ inserts.push(`<Override PartName="${partName}" ContentType="${contentType}"/>`);
253
+ }
254
+ if (inserts.length > 0) {
255
+ ct = ct.replace('</Types>', `${inserts.join('')}</Types>`);
256
+ zip.file('[Content_Types].xml', ct);
257
+ }
258
+ }
259
+
260
+ // 3. Patch xl/_rels/workbook.xml.rels — append the captured
261
+ // pivotCacheDefinition rels with fresh rIds. Track the
262
+ // origId → newId mapping for the <pivotCaches> patch in step 4.
263
+ const workbookRelsPath = 'xl/_rels/workbook.xml.rels';
264
+ const workbookRelsEntry = zip.file(workbookRelsPath);
265
+ const cacheIdRemap = new Map<string, string>();
266
+ if (workbookRelsEntry && payload.workbookCacheRels.length > 0) {
267
+ let rels = await workbookRelsEntry.async('string');
268
+ const used = new Set<number>();
269
+ for (const m of rels.matchAll(/Id="rId(\d+)"/g)) used.add(Number(m[1]));
270
+ const assign = nextRidIssuer(used);
271
+ const additions: string[] = [];
272
+ for (const r of payload.workbookCacheRels) {
273
+ const newId = assign();
274
+ cacheIdRemap.set(r.origId, newId);
275
+ additions.push(
276
+ `<Relationship Id="${newId}" Type="${REL_TYPE_PIVOT_CACHE_DEF}" Target="${r.target}"/>`,
277
+ );
278
+ }
279
+ rels = rels.replace('</Relationships>', `${additions.join('')}</Relationships>`);
280
+ zip.file(workbookRelsPath, rels);
281
+ }
282
+
283
+ // 4. Patch xl/workbook.xml — inject <pivotCaches> with the
284
+ // remapped rIds. The captured snippet has the original rIds;
285
+ // we substitute them in place via the cacheIdRemap.
286
+ const workbookXmlEntry = zip.file('xl/workbook.xml');
287
+ if (workbookXmlEntry && payload.workbookPivotCachesXml) {
288
+ let workbookXml = await workbookXmlEntry.async('string');
289
+ // Remap r:id references inside the captured snippet.
290
+ let snippet = payload.workbookPivotCachesXml;
291
+ for (const [origId, newId] of cacheIdRemap) {
292
+ // Anchor on quote boundaries so we don't accidentally rewrite
293
+ // a longer rId that prefix-overlaps (rId1 vs rId10).
294
+ snippet = snippet.replaceAll(`"${origId}"`, `"${newId}"`);
295
+ }
296
+ // ExcelJS regenerates workbook.xml so it never contains a
297
+ // <pivotCaches> element. Inject ours immediately after </sheets>
298
+ // (the OOXML-correct position).
299
+ if (!workbookXml.includes('<pivotCaches')) {
300
+ workbookXml = workbookXml.replace('</sheets>', `</sheets>${snippet}`);
301
+ zip.file('xl/workbook.xml', workbookXml);
302
+ }
303
+ }
304
+
305
+ // 5. Patch each sheet's .rels — append captured pivotTable rels
306
+ // with fresh rIds. Sheet matching is by NAME via the regenerated
307
+ // workbook.xml; sheet file names may have shifted.
308
+ if (workbookXmlEntry && payload.perSheet.length > 0) {
309
+ const workbookXml = await workbookXmlEntry.async('string');
310
+ const workbookRels = workbookRelsEntry
311
+ ? parseRelationships(await zip.file(workbookRelsPath)!.async('string'))
312
+ : [];
313
+
314
+ const sheetByName = new Map<string, string>();
315
+ for (const m of workbookXml.matchAll(/<sheet\b[^>]*\/>/g)) {
316
+ const tag = m[0];
317
+ const name = tag.match(/name="([^"]+)"/)?.[1];
318
+ const rId =
319
+ tag.match(/r:id="([^"]+)"/i)?.[1] ?? tag.match(/r:Id="([^"]+)"/)?.[1];
320
+ if (!name || !rId) continue;
321
+ const rel = workbookRels.find((r) => r.id === rId);
322
+ if (!rel) continue;
323
+ sheetByName.set(name, rel.target);
324
+ }
325
+
326
+ for (const sheet of payload.perSheet) {
327
+ const targetPath = sheetByName.get(sheet.sheetName);
328
+ if (!targetPath) continue; // sheet renamed or removed — skip
329
+ const fileName = targetPath.split('/').pop()!;
330
+ const sheetRelsPath = `xl/worksheets/_rels/${fileName}.rels`;
331
+ let sheetRels =
332
+ (await zip.file(sheetRelsPath)?.async('string')) ??
333
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
334
+ const used = new Set<number>();
335
+ for (const m of sheetRels.matchAll(/Id="rId(\d+)"/g)) used.add(Number(m[1]));
336
+ const assign = nextRidIssuer(used);
337
+ const additions: string[] = [];
338
+ for (const r of sheet.pivotTableRels) {
339
+ const newId = assign();
340
+ additions.push(
341
+ `<Relationship Id="${newId}" Type="${REL_TYPE_PIVOT_TABLE}" Target="${r.target}"/>`,
342
+ );
343
+ }
344
+ if (sheetRels.includes('</Relationships>')) {
345
+ sheetRels = sheetRels.replace(
346
+ '</Relationships>',
347
+ `${additions.join('')}</Relationships>`,
348
+ );
349
+ } else {
350
+ sheetRels += `\n${additions.join('')}`;
351
+ }
352
+ zip.file(sheetRelsPath, sheetRels);
353
+ }
354
+ }
355
+
356
+ // unused — suppress TS unused-var noise on the helper. (We use
357
+ // extractRid + the constants in other branches of the file.)
358
+ void extractRid;
359
+ }