@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,418 @@
|
|
|
1
|
+
import ExcelJS from 'exceljs';
|
|
2
|
+
import {
|
|
3
|
+
CustomRangeType,
|
|
4
|
+
LocaleType,
|
|
5
|
+
type ICellData,
|
|
6
|
+
type IRange,
|
|
7
|
+
type IStyleData,
|
|
8
|
+
type IWorkbookData,
|
|
9
|
+
} from '@univerjs/core';
|
|
10
|
+
import { excelStyleToUniver } from './style-mapping';
|
|
11
|
+
import { INITIAL_COLUMNS, INITIAL_ROWS, UNIVER_VERSION } from './_snapshot-constants';
|
|
12
|
+
import { RESOURCES_SHEET } from './constants';
|
|
13
|
+
import {
|
|
14
|
+
mergeCommentsIntoResources,
|
|
15
|
+
readCommentsFromXlsx,
|
|
16
|
+
} from './comments-resource';
|
|
17
|
+
import {
|
|
18
|
+
mergePageSetupIntoResources,
|
|
19
|
+
readPageSetupFromXlsx,
|
|
20
|
+
} from './page-setup-resource';
|
|
21
|
+
import {
|
|
22
|
+
mergeDataValidationIntoResources,
|
|
23
|
+
readDataValidationFromXlsx,
|
|
24
|
+
} from './data-validation-resource';
|
|
25
|
+
import {
|
|
26
|
+
mergeTablesIntoResources,
|
|
27
|
+
readTablesFromXlsx,
|
|
28
|
+
} from './tables-resource';
|
|
29
|
+
import {
|
|
30
|
+
capturePassthroughFromBuffer,
|
|
31
|
+
mergePassthroughIntoResources,
|
|
32
|
+
} from './passthrough-resource';
|
|
33
|
+
import type { ImportedWorkbook } from './import';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure conversion: ExcelJS workbook → Univer IWorkbookData snapshot.
|
|
37
|
+
* Imported by both `parser.worker.ts` (where it actually runs) and the
|
|
38
|
+
* type-only `import.ts` (for the public types). Splitting this out keeps
|
|
39
|
+
* ExcelJS — which is large — out of the main bundle; only the worker
|
|
40
|
+
* chunk pays for it.
|
|
41
|
+
*
|
|
42
|
+
* Behavior is byte-for-byte equivalent to the previous in-place impl;
|
|
43
|
+
* see import.ts header for the fidelity scope.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
let hyperlinkIdCounter = 0;
|
|
47
|
+
const nextHyperlinkId = () =>
|
|
48
|
+
`hl-${Date.now().toString(36)}-${(hyperlinkIdCounter++).toString(36)}`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a rich-text doc body that encodes a hyperlink at the cell. The
|
|
52
|
+
* shape mirrors what `AddHyperLinkCommand` writes into `cell.p.body`
|
|
53
|
+
* (see vendor: sheets-hyper-link's AddHyperLinkCommand impl). Putting
|
|
54
|
+
* hyperlinks here at import time avoids the previous per-link serial
|
|
55
|
+
* `executeCommand` round-trip after mount — for a workbook with
|
|
56
|
+
* thousands of links that path took multiple seconds.
|
|
57
|
+
*/
|
|
58
|
+
function buildHyperlinkBody(display: string, url: string, id: string): ICellData['p'] {
|
|
59
|
+
// Univer doc bodies end with `\r\n` (paragraph + section break). The
|
|
60
|
+
// customRange covers only the visible text [0, display.length - 1].
|
|
61
|
+
const dataStream = `${display}\r\n`;
|
|
62
|
+
return {
|
|
63
|
+
id: '__INTERNAL_EDITOR__DOCS_NORMAL',
|
|
64
|
+
documentStyle: {},
|
|
65
|
+
body: {
|
|
66
|
+
dataStream,
|
|
67
|
+
customRanges: [
|
|
68
|
+
{
|
|
69
|
+
startIndex: 0,
|
|
70
|
+
endIndex: display.length - 1,
|
|
71
|
+
rangeType: CustomRangeType.HYPERLINK,
|
|
72
|
+
rangeId: id,
|
|
73
|
+
properties: { url },
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
paragraphs: [{ startIndex: display.length }],
|
|
77
|
+
sectionBreaks: [{ startIndex: display.length + 1 }],
|
|
78
|
+
textRuns: [],
|
|
79
|
+
},
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
|
+
} as any;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readResourcesSheet(ws: ExcelJS.Worksheet): IWorkbookData['resources'] {
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
ws.eachRow({ includeEmpty: false }, (row) => {
|
|
87
|
+
const v = row.getCell(1).value;
|
|
88
|
+
if (typeof v === 'string') parts.push(v);
|
|
89
|
+
});
|
|
90
|
+
if (parts.length === 0) return undefined;
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(parts.join(''));
|
|
93
|
+
if (Array.isArray(parsed)) return parsed as IWorkbookData['resources'];
|
|
94
|
+
} catch {
|
|
95
|
+
/* corrupt blob — drop silently, the workbook still opens */
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pull `<definedName>` entries out of the xlsx workbook part and merge
|
|
102
|
+
* them into the SHEET_DEFINED_NAME_PLUGIN resource Univer reads on
|
|
103
|
+
* load. Our own sidecar (`__casual_sheets_resources__`) round-trips the
|
|
104
|
+
* full Univer shape (id, comment, hidden, etc.); xlsx-native defined
|
|
105
|
+
* names only carry `name` + `ranges`, so we synthesise the missing
|
|
106
|
+
* fields on the fly. Skipped when the sidecar already provided the
|
|
107
|
+
* resource — it has the richer payload.
|
|
108
|
+
*/
|
|
109
|
+
const DEFINED_NAMES_RESOURCE = 'SHEET_DEFINED_NAME_PLUGIN';
|
|
110
|
+
function mergeDefinedNamesFromXlsx(
|
|
111
|
+
wb: ExcelJS.Workbook,
|
|
112
|
+
resources: IWorkbookData['resources'],
|
|
113
|
+
): IWorkbookData['resources'] {
|
|
114
|
+
const existing = resources?.find((r) => r.name === DEFINED_NAMES_RESOURCE);
|
|
115
|
+
if (existing) return resources;
|
|
116
|
+
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
+
const model = (wb.definedNames as any).model as
|
|
119
|
+
| Array<{ name: string; ranges: string[] }>
|
|
120
|
+
| undefined;
|
|
121
|
+
if (!model?.length) return resources;
|
|
122
|
+
|
|
123
|
+
const map: Record<string, {
|
|
124
|
+
id: string;
|
|
125
|
+
name: string;
|
|
126
|
+
formulaOrRefString: string;
|
|
127
|
+
}> = {};
|
|
128
|
+
let i = 0;
|
|
129
|
+
for (const dn of model) {
|
|
130
|
+
if (!dn?.name || !Array.isArray(dn.ranges) || dn.ranges.length === 0) continue;
|
|
131
|
+
const id = `dn-${i++}`;
|
|
132
|
+
// Multi-range defined names get comma-joined; Univer's formula
|
|
133
|
+
// engine accepts that shape (range A, range B → "A,B").
|
|
134
|
+
map[id] = {
|
|
135
|
+
id,
|
|
136
|
+
name: dn.name,
|
|
137
|
+
formulaOrRefString: dn.ranges.join(','),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (Object.keys(map).length === 0) return resources;
|
|
141
|
+
|
|
142
|
+
const next = [...(resources ?? [])];
|
|
143
|
+
next.push({ name: DEFINED_NAMES_RESOURCE, data: JSON.stringify(map) });
|
|
144
|
+
return next;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lettersToCol(letters: string): number {
|
|
148
|
+
let col = 0;
|
|
149
|
+
for (let i = 0; i < letters.length; i++) {
|
|
150
|
+
col = col * 26 + (letters.charCodeAt(i) - 64);
|
|
151
|
+
}
|
|
152
|
+
return col - 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function workbookFromExcelJs(buffer: ArrayBuffer): Promise<ImportedWorkbook> {
|
|
156
|
+
// Capture raw OOXML parts ExcelJS drops (today: xl/vbaProject.bin) before
|
|
157
|
+
// it consumes the buffer. Reads the same bytes twice — once as zip here,
|
|
158
|
+
// once as ExcelJS below — but the second JSZip pass is ~tens of ms even
|
|
159
|
+
// for big files and means we don't lose macros on round-trip.
|
|
160
|
+
const passthrough = await capturePassthroughFromBuffer(buffer);
|
|
161
|
+
|
|
162
|
+
const wb = new ExcelJS.Workbook();
|
|
163
|
+
await wb.xlsx.load(buffer);
|
|
164
|
+
|
|
165
|
+
const id = `wb-${Date.now()}`;
|
|
166
|
+
const sheetOrder: string[] = [];
|
|
167
|
+
const sheets: IWorkbookData['sheets'] = {};
|
|
168
|
+
const styles: Record<string, IStyleData | null> = {};
|
|
169
|
+
let styleCounter = 0;
|
|
170
|
+
|
|
171
|
+
const styleByKey = new Map<string, string>();
|
|
172
|
+
const internStyle = (style: IStyleData | undefined): string | undefined => {
|
|
173
|
+
if (!style) return undefined;
|
|
174
|
+
const key = JSON.stringify(style);
|
|
175
|
+
const existing = styleByKey.get(key);
|
|
176
|
+
if (existing) return existing;
|
|
177
|
+
const styleId = `s${styleCounter++}`;
|
|
178
|
+
styleByKey.set(key, styleId);
|
|
179
|
+
styles[styleId] = style;
|
|
180
|
+
return styleId;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const PX_PER_CHAR = 7;
|
|
184
|
+
const charsToPx = (chars: number) => Math.round(chars * PX_PER_CHAR);
|
|
185
|
+
const pointsToPx = (pt: number) => Math.round((pt * 96) / 72);
|
|
186
|
+
|
|
187
|
+
let resources: IWorkbookData['resources'] | undefined;
|
|
188
|
+
for (const ws of wb.worksheets) {
|
|
189
|
+
if (ws.name === RESOURCES_SHEET) {
|
|
190
|
+
resources = readResourcesSheet(ws);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Mirror xlsx-native defined names into the Univer plugin resource so
|
|
196
|
+
// a file authored in real Excel keeps its named ranges. The hidden
|
|
197
|
+
// sidecar (`__casual_sheets_resources__`) carries the full Univer
|
|
198
|
+
// shape on our own round-trip — only fall back to xlsx defined names
|
|
199
|
+
// when the sidecar didn't already register them.
|
|
200
|
+
resources = mergeDefinedNamesFromXlsx(wb, resources);
|
|
201
|
+
|
|
202
|
+
// Same fall-back path for xlsx-native cell comments — files
|
|
203
|
+
// authored in real Excel keep their comments when opened here,
|
|
204
|
+
// and the audit's round-trip probe on `B2 comment text` passes.
|
|
205
|
+
const xlsxComments = readCommentsFromXlsx(wb, id, (excelId) => `sheet-${excelId}`);
|
|
206
|
+
resources = mergeCommentsIntoResources(resources, xlsxComments);
|
|
207
|
+
|
|
208
|
+
// Page-setup chrome (orientation, paper, margins, header/footer)
|
|
209
|
+
// is a passthrough sidecar — Univer doesn't model it; the
|
|
210
|
+
// exporter re-applies on save so the print metadata isn't lost.
|
|
211
|
+
const xlsxPageSetup = readPageSetupFromXlsx(wb, (excelId) => `sheet-${excelId}`);
|
|
212
|
+
resources = mergePageSetupIntoResources(resources, xlsxPageSetup);
|
|
213
|
+
|
|
214
|
+
// xlsx-native data validations → Univer's data-validation plugin
|
|
215
|
+
// resource. Using the plugin's exact resource name means the rule
|
|
216
|
+
// live-loads into the model on re-open (visible in the validation
|
|
217
|
+
// panel + enforced on edit), not just sitting in our sidecar.
|
|
218
|
+
const xlsxDv = readDataValidationFromXlsx(wb, (excelId) => `sheet-${excelId}`);
|
|
219
|
+
resources = mergeDataValidationIntoResources(resources, xlsxDv);
|
|
220
|
+
|
|
221
|
+
// xlsx ListObjects → passthrough table sidecar. Round-trips via
|
|
222
|
+
// `ws.addTable(...)` on save so the table definition survives even
|
|
223
|
+
// though Univer can't render the table chrome yet.
|
|
224
|
+
const xlsxTables = readTablesFromXlsx(wb, (excelId) => `sheet-${excelId}`);
|
|
225
|
+
resources = mergeTablesIntoResources(resources, xlsxTables);
|
|
226
|
+
|
|
227
|
+
// Raw OOXML parts we captured up top — stash them on the snapshot so
|
|
228
|
+
// the exporter can re-inject them and the file round-trips as .xlsm
|
|
229
|
+
// when it carried macros.
|
|
230
|
+
resources = mergePassthroughIntoResources(resources, passthrough);
|
|
231
|
+
|
|
232
|
+
for (const ws of wb.worksheets) {
|
|
233
|
+
if (ws.name === RESOURCES_SHEET) continue;
|
|
234
|
+
const sheetId = `sheet-${ws.id}`;
|
|
235
|
+
sheetOrder.push(sheetId);
|
|
236
|
+
|
|
237
|
+
const cellData: Record<number, Record<number, ICellData>> = {};
|
|
238
|
+
const mergeData: IRange[] = [];
|
|
239
|
+
const columnData: Record<number, { w?: number; hd?: number }> = {};
|
|
240
|
+
const rowData: Record<number, { h?: number; hd?: number }> = {};
|
|
241
|
+
|
|
242
|
+
let maxRow = 0;
|
|
243
|
+
let maxCol = 0;
|
|
244
|
+
|
|
245
|
+
ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
246
|
+
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
|
247
|
+
const r = rowNumber - 1;
|
|
248
|
+
const c = colNumber - 1;
|
|
249
|
+
maxRow = Math.max(maxRow, r);
|
|
250
|
+
maxCol = Math.max(maxCol, c);
|
|
251
|
+
|
|
252
|
+
const cd: ICellData = {};
|
|
253
|
+
const raw = cell.value;
|
|
254
|
+
if (raw && typeof raw === 'object' && 'formula' in raw) {
|
|
255
|
+
const f = (raw as { formula: string }).formula;
|
|
256
|
+
cd.f = f.startsWith('=') ? f : `=${f}`;
|
|
257
|
+
const result = (raw as { result?: unknown }).result;
|
|
258
|
+
if (result !== undefined && result !== null && typeof result !== 'object') {
|
|
259
|
+
cd.v = result as ICellData['v'];
|
|
260
|
+
}
|
|
261
|
+
} else if (raw && typeof raw === 'object' && 'richText' in raw) {
|
|
262
|
+
cd.v = (raw as { richText: { text: string }[] }).richText.map((t) => t.text).join('');
|
|
263
|
+
} else if (raw && typeof raw === 'object' && 'text' in raw && 'hyperlink' in raw) {
|
|
264
|
+
const display = (raw as { text: string }).text ?? '';
|
|
265
|
+
cd.v = display;
|
|
266
|
+
const url = (raw as { hyperlink: string }).hyperlink;
|
|
267
|
+
if (typeof url === 'string' && url && display.length > 0) {
|
|
268
|
+
// Inline the link into cell.p so it ships in the snapshot.
|
|
269
|
+
// No need to replay through AddHyperLinkCommand at mount.
|
|
270
|
+
cd.p = buildHyperlinkBody(display, url, nextHyperlinkId());
|
|
271
|
+
}
|
|
272
|
+
} else if (raw && typeof raw === 'object' && 'sharedFormula' in raw) {
|
|
273
|
+
const sf = (raw as { sharedFormula: string; result?: unknown }).sharedFormula;
|
|
274
|
+
cd.f = sf.startsWith('=') ? sf : `=${sf}`;
|
|
275
|
+
const result = (raw as { result?: unknown }).result;
|
|
276
|
+
if (result !== undefined && result !== null && typeof result !== 'object') {
|
|
277
|
+
cd.v = result as ICellData['v'];
|
|
278
|
+
}
|
|
279
|
+
} else if (raw instanceof Date) {
|
|
280
|
+
cd.v = raw.toISOString();
|
|
281
|
+
} else if (typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'string') {
|
|
282
|
+
cd.v = raw;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const styleId = internStyle(excelStyleToUniver(cell));
|
|
286
|
+
if (styleId) cd.s = styleId;
|
|
287
|
+
|
|
288
|
+
if (cd.v !== undefined || cd.f || cd.s) {
|
|
289
|
+
cellData[r] ??= {};
|
|
290
|
+
cellData[r][c] = cd;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const merges = (ws.model as { merges?: string[] }).merges ?? [];
|
|
296
|
+
for (const range of merges) {
|
|
297
|
+
const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(range);
|
|
298
|
+
if (!m) continue;
|
|
299
|
+
const [, startColL, startRowS, endColL, endRowS] = m;
|
|
300
|
+
const start = { row: Number(startRowS) - 1, col: lettersToCol(startColL) };
|
|
301
|
+
const end = { row: Number(endRowS) - 1, col: lettersToCol(endColL) };
|
|
302
|
+
mergeData.push({
|
|
303
|
+
startRow: start.row,
|
|
304
|
+
startColumn: start.col,
|
|
305
|
+
endRow: end.row,
|
|
306
|
+
endColumn: end.col,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const wsColumns = (ws as { columns?: Array<{ width?: number; hidden?: boolean } | null> })
|
|
311
|
+
.columns ?? [];
|
|
312
|
+
wsColumns.forEach((col, i) => {
|
|
313
|
+
if (!col) return;
|
|
314
|
+
const entry: { w?: number; hd?: number } = {};
|
|
315
|
+
if (typeof col.width === 'number') entry.w = charsToPx(col.width);
|
|
316
|
+
if (col.hidden) entry.hd = 1;
|
|
317
|
+
if (entry.w !== undefined || entry.hd !== undefined) columnData[i] = entry;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
ws.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
321
|
+
const entry: { h?: number; hd?: number } = {};
|
|
322
|
+
if (typeof row.height === 'number') entry.h = pointsToPx(row.height);
|
|
323
|
+
if (row.hidden) entry.hd = 1;
|
|
324
|
+
if (entry.h !== undefined || entry.hd !== undefined) rowData[rowNumber - 1] = entry;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
let freeze: { xSplit: number; ySplit: number; startRow: number; startColumn: number } | undefined;
|
|
328
|
+
const view = (ws as { views?: Array<{ state?: string; xSplit?: number; ySplit?: number }> })
|
|
329
|
+
.views?.[0];
|
|
330
|
+
if (view?.state === 'frozen') {
|
|
331
|
+
const xSplit = view.xSplit ?? 0;
|
|
332
|
+
const ySplit = view.ySplit ?? 0;
|
|
333
|
+
if (xSplit > 0 || ySplit > 0) {
|
|
334
|
+
freeze = {
|
|
335
|
+
xSplit,
|
|
336
|
+
ySplit,
|
|
337
|
+
startRow: ySplit > 0 ? ySplit : -1,
|
|
338
|
+
startColumn: xSplit > 0 ? xSplit : -1,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const argb = ws.properties?.tabColor?.argb;
|
|
344
|
+
const tabColor =
|
|
345
|
+
argb && /^[0-9A-Fa-f]{8}$/.test(argb) ? `#${argb.slice(2).toLowerCase()}` : undefined;
|
|
346
|
+
|
|
347
|
+
const defaultColumnWidth =
|
|
348
|
+
typeof ws.properties?.defaultColWidth === 'number'
|
|
349
|
+
? charsToPx(ws.properties.defaultColWidth)
|
|
350
|
+
: undefined;
|
|
351
|
+
const defaultRowHeight =
|
|
352
|
+
typeof ws.properties?.defaultRowHeight === 'number'
|
|
353
|
+
? pointsToPx(ws.properties.defaultRowHeight)
|
|
354
|
+
: undefined;
|
|
355
|
+
|
|
356
|
+
const hidden = (ws as { state?: string }).state === 'hidden' ? 1 : undefined;
|
|
357
|
+
|
|
358
|
+
sheets[sheetId] = {
|
|
359
|
+
id: sheetId,
|
|
360
|
+
name: ws.name,
|
|
361
|
+
cellData,
|
|
362
|
+
mergeData,
|
|
363
|
+
columnData,
|
|
364
|
+
rowData,
|
|
365
|
+
rowCount: Math.max(INITIAL_ROWS, maxRow + 1),
|
|
366
|
+
columnCount: Math.max(INITIAL_COLUMNS, maxCol + 1),
|
|
367
|
+
...(freeze ? { freeze } : {}),
|
|
368
|
+
...(tabColor ? { tabColor } : {}),
|
|
369
|
+
...(defaultColumnWidth !== undefined ? { defaultColumnWidth } : {}),
|
|
370
|
+
...(defaultRowHeight !== undefined ? { defaultRowHeight } : {}),
|
|
371
|
+
...(hidden ? { hidden } : {}),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (sheetOrder.length === 0) {
|
|
376
|
+
sheetOrder.push('sheet-1');
|
|
377
|
+
sheets['sheet-1'] = {
|
|
378
|
+
id: 'sheet-1',
|
|
379
|
+
name: 'Sheet1',
|
|
380
|
+
cellData: {},
|
|
381
|
+
rowCount: INITIAL_ROWS,
|
|
382
|
+
columnCount: INITIAL_COLUMNS,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
id,
|
|
388
|
+
rev: 1,
|
|
389
|
+
name: wb.title || 'Untitled',
|
|
390
|
+
appVersion: UNIVER_VERSION,
|
|
391
|
+
locale: LocaleType.EN_US,
|
|
392
|
+
styles,
|
|
393
|
+
sheetOrder,
|
|
394
|
+
sheets,
|
|
395
|
+
...(resources ? { resources } : {}),
|
|
396
|
+
custom: {
|
|
397
|
+
// Map xlsx Core/App properties back into our `custom.properties`
|
|
398
|
+
// slot so the Properties dialog shows what the file actually
|
|
399
|
+
// carried. The exporter mirrors the same names in reverse.
|
|
400
|
+
properties: {
|
|
401
|
+
...(wb.title ? { title: wb.title } : {}),
|
|
402
|
+
...(wb.subject ? { subject: wb.subject } : {}),
|
|
403
|
+
...(wb.description ? { description: wb.description } : {}),
|
|
404
|
+
...(wb.keywords ? { tags: wb.keywords } : {}),
|
|
405
|
+
...(wb.category ? { category: wb.category } : {}),
|
|
406
|
+
...(wb.company ? { company: wb.company } : {}),
|
|
407
|
+
...(wb.manager ? { manager: wb.manager } : {}),
|
|
408
|
+
...(wb.creator ? { author: wb.creator } : {}),
|
|
409
|
+
...(wb.created instanceof Date && !isNaN(wb.created.getTime())
|
|
410
|
+
? { createdAt: wb.created.toISOString() }
|
|
411
|
+
: {}),
|
|
412
|
+
...(wb.modified instanceof Date && !isNaN(wb.modified.getTime())
|
|
413
|
+
? { modifiedAt: wb.modified.toISOString() }
|
|
414
|
+
: {}),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ImportedWorkbook } from './import';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Main-thread entry point for xlsx parsing. Spawns a Web Worker,
|
|
5
|
+
* transfers the buffer in (zero-copy), awaits a single response,
|
|
6
|
+
* terminates the worker. The buffer becomes detached on the main
|
|
7
|
+
* side after transfer — callers must not reuse it.
|
|
8
|
+
*
|
|
9
|
+
* Why a fresh worker per parse: parses are rare (open / drop /
|
|
10
|
+
* collab seed) and the worker carries a ~600 KB ExcelJS payload —
|
|
11
|
+
* keeping it alive between parses would idle that memory. A pool
|
|
12
|
+
* is a follow-up optimization if we see many sequential opens.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let nextId = 0;
|
|
16
|
+
|
|
17
|
+
// Cap the wait at 3 minutes. A multi-hundred-MB ExcelJS parse can take
|
|
18
|
+
// over a minute on slow hardware; anything past 3 minutes is almost
|
|
19
|
+
// certainly a hung/deadlocked worker (e.g. crashed without firing
|
|
20
|
+
// `error`). Reject so the UI shows an actionable message instead of
|
|
21
|
+
// spinning forever.
|
|
22
|
+
const PARSE_TIMEOUT_MS = 180_000;
|
|
23
|
+
|
|
24
|
+
export function parseXlsxInWorker(buffer: ArrayBuffer): Promise<ImportedWorkbook> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const worker = new Worker(new URL('./parser.worker.ts', import.meta.url), {
|
|
27
|
+
type: 'module',
|
|
28
|
+
name: 'xlsx-parser',
|
|
29
|
+
});
|
|
30
|
+
const id = ++nextId;
|
|
31
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
const cleanup = () => {
|
|
33
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
34
|
+
worker.terminate();
|
|
35
|
+
};
|
|
36
|
+
timeoutHandle = setTimeout(() => {
|
|
37
|
+
cleanup();
|
|
38
|
+
reject(
|
|
39
|
+
new Error(
|
|
40
|
+
`xlsx parser worker timed out after ${Math.round(PARSE_TIMEOUT_MS / 1000)}s. The workbook may be corrupted or too large to parse in this browser.`,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
}, PARSE_TIMEOUT_MS);
|
|
44
|
+
worker.addEventListener(
|
|
45
|
+
'message',
|
|
46
|
+
(e: MessageEvent<{ id: number; ok: true; data: ImportedWorkbook } | { id: number; ok: false; error: string }>) => {
|
|
47
|
+
const msg = e.data;
|
|
48
|
+
if (msg.id !== id) return;
|
|
49
|
+
cleanup();
|
|
50
|
+
if (msg.ok) resolve(msg.data);
|
|
51
|
+
else reject(new Error(msg.error));
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
worker.addEventListener('error', (e) => {
|
|
55
|
+
cleanup();
|
|
56
|
+
// ErrorEvent.message is "" when the worker crashes from OOM in
|
|
57
|
+
// some browsers; with no message AND no filename it's almost
|
|
58
|
+
// always OOM mid-allocation. Emit a clear hint instead of the
|
|
59
|
+
// useless "(unknown)" we used to show.
|
|
60
|
+
const hasDetail = Boolean(e.message || e.filename);
|
|
61
|
+
if (!hasDetail) {
|
|
62
|
+
reject(
|
|
63
|
+
new Error(
|
|
64
|
+
'xlsx parser worker ran out of memory parsing this file. Try a smaller workbook or close other browser tabs.',
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const detail = e.message || `${e.filename ?? ''}:${e.lineno ?? ''}`;
|
|
70
|
+
reject(
|
|
71
|
+
new Error(
|
|
72
|
+
`xlsx parser worker crashed (${detail}). The workbook may be too large for this browser to allocate.`,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
worker.addEventListener('messageerror', () => {
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(new Error('xlsx parser worker returned an unserializable result.'));
|
|
79
|
+
});
|
|
80
|
+
worker.postMessage({ id, buffer }, [buffer]);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
|
|
3
|
+
import { workbookFromExcelJs } from './parse-impl';
|
|
4
|
+
import type { ImportedWorkbook } from './import';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worker side of the xlsx parser. Receives an ArrayBuffer via the
|
|
8
|
+
* transferable list (zero-copy in), runs the pure ExcelJS conversion,
|
|
9
|
+
* sends back the IWorkbookData. Errors are stringified — Workbook
|
|
10
|
+
* objects don't structured-clone, but plain JSON does.
|
|
11
|
+
*
|
|
12
|
+
* The worker is single-shot per parse: parse-in-worker.ts spawns,
|
|
13
|
+
* posts, awaits, terminates. Keeping it stateless side-steps any
|
|
14
|
+
* worker-lifecycle bookkeeping for now; if we end up parsing many
|
|
15
|
+
* small workbooks in succession we can switch to a pooled model.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type ParseRequest = { id: number; buffer: ArrayBuffer };
|
|
19
|
+
type ParseResponse =
|
|
20
|
+
| { id: number; ok: true; data: ImportedWorkbook }
|
|
21
|
+
| { id: number; ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
const ctx = self as unknown as DedicatedWorkerGlobalScope;
|
|
24
|
+
|
|
25
|
+
ctx.addEventListener('message', async (e: MessageEvent<ParseRequest>) => {
|
|
26
|
+
const { id, buffer } = e.data;
|
|
27
|
+
try {
|
|
28
|
+
const data = await workbookFromExcelJs(buffer);
|
|
29
|
+
const reply: ParseResponse = { id, ok: true, data };
|
|
30
|
+
ctx.postMessage(reply);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// Preserve the underlying error type + stack so the main-thread
|
|
33
|
+
// overlay can show something more useful than "Error code: 5".
|
|
34
|
+
const error =
|
|
35
|
+
err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err);
|
|
36
|
+
const reply: ParseResponse = { id, ok: false, error };
|
|
37
|
+
ctx.postMessage(reply);
|
|
38
|
+
}
|
|
39
|
+
});
|