@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.
- package/LICENSE +200 -0
- package/dist/embed/embed-runtime.js +537 -0
- package/dist/embed/embed.html +29 -0
- package/dist/embed/parser.worker.js +48474 -0
- package/dist/embed.cjs +225 -0
- package/dist/embed.cjs.map +1 -0
- package/dist/embed.d.cts +100 -0
- package/dist/embed.d.ts +100 -0
- package/dist/embed.js +204 -0
- package/dist/embed.js.map +1 -0
- package/dist/index.cjs +1549 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1530 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.worker.cjs +48469 -0
- package/dist/parser.worker.cjs.map +1 -0
- package/dist/parser.worker.js +48474 -0
- package/dist/parser.worker.js.map +1 -0
- package/dist/protocol--KyBQUjU.d.cts +171 -0
- package/dist/protocol-cEzy7S0i.d.ts +171 -0
- package/dist/sheets.cjs +677 -0
- package/dist/sheets.cjs.map +1 -0
- package/dist/sheets.d.cts +177 -0
- package/dist/sheets.d.ts +177 -0
- package/dist/sheets.js +658 -0
- package/dist/sheets.js.map +1 -0
- package/dist/signing.cjs +706 -0
- package/dist/signing.cjs.map +1 -0
- package/dist/signing.d.cts +141 -0
- package/dist/signing.d.ts +141 -0
- package/dist/signing.js +683 -0
- package/dist/signing.js.map +1 -0
- package/dist/styles.cjs +10 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.d.cts +2 -0
- package/dist/styles.d.ts +2 -0
- package/dist/styles.js +8 -0
- package/dist/styles.js.map +1 -0
- package/dist/types-s_O0u6Cg.d.cts +90 -0
- package/dist/types-s_O0u6Cg.d.ts +90 -0
- package/dist/univer.cjs +220 -0
- package/dist/univer.cjs.map +1 -0
- package/dist/univer.d.cts +60 -0
- package/dist/univer.d.ts +60 -0
- package/dist/univer.js +187 -0
- package/dist/univer.js.map +1 -0
- package/dist/xlsx.cjs +3388 -0
- package/dist/xlsx.cjs.map +1 -0
- package/dist/xlsx.d.cts +383 -0
- package/dist/xlsx.d.ts +383 -0
- package/dist/xlsx.js +3383 -0
- package/dist/xlsx.js.map +1 -0
- package/package.json +293 -0
- package/src/embed/EmbedHostTransport.ts +226 -0
- package/src/embed/EmbedTransport.ts +323 -0
- package/src/embed/EmbedTransport.unit.test.ts +161 -0
- package/src/embed/index.ts +40 -0
- package/src/embed/protocol.ts +258 -0
- package/src/embed-runtime/embed.html +29 -0
- package/src/embed-runtime/index.tsx +440 -0
- package/src/index.ts +16 -0
- package/src/sheets/CasualSheets.tsx +319 -0
- package/src/sheets/CasualSheetsIframe.tsx +220 -0
- package/src/sheets/api.ts +108 -0
- package/src/sheets/index.ts +11 -0
- package/src/signing/SigningPane.tsx +374 -0
- package/src/signing/SigningProvider.tsx +126 -0
- package/src/signing/captures.tsx +316 -0
- package/src/signing/controller.ts +151 -0
- package/src/signing/controller.unit.test.ts +133 -0
- package/src/signing/index.ts +44 -0
- package/src/signing/types.ts +89 -0
- package/src/styles.ts +16 -0
- package/src/univer/index.ts +17 -0
- package/src/univer/lazy-plugins.ts +280 -0
- package/src/xlsx/_perf.ts +14 -0
- package/src/xlsx/_snapshot-constants.ts +15 -0
- package/src/xlsx/comments-resource.ts +209 -0
- package/src/xlsx/constants.ts +9 -0
- package/src/xlsx/data-validation-resource.ts +219 -0
- package/src/xlsx/import.ts +35 -0
- package/src/xlsx/index.ts +40 -0
- package/src/xlsx/page-setup-resource.ts +205 -0
- package/src/xlsx/parse-impl.ts +418 -0
- package/src/xlsx/parse-in-worker.ts +82 -0
- package/src/xlsx/parser.worker.ts +39 -0
- package/src/xlsx/passthrough-resource.ts +175 -0
- package/src/xlsx/pivot-passthrough.ts +359 -0
- package/src/xlsx/style-mapping.ts +171 -0
- 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
|
+
}
|