clear-excel 0.1.0 → 1.0.2

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 (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +120 -72
  3. package/dist/api/sanitize-excel-file.d.ts +5 -0
  4. package/dist/api/sanitize-excel-file.d.ts.map +1 -0
  5. package/dist/api/sanitize-excel.d.ts +3 -0
  6. package/dist/api/sanitize-excel.d.ts.map +1 -0
  7. package/dist/biff/workbook.d.ts +4 -0
  8. package/dist/biff/workbook.d.ts.map +1 -0
  9. package/dist/constants.d.ts +6 -0
  10. package/dist/constants.d.ts.map +1 -0
  11. package/dist/errors.d.ts +20 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/formats.d.ts +5 -0
  14. package/dist/formats.d.ts.map +1 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +2777 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/ooxml/cleanup.d.ts +3 -0
  20. package/dist/ooxml/cleanup.d.ts.map +1 -0
  21. package/dist/ooxml/constants.d.ts +4 -0
  22. package/dist/ooxml/constants.d.ts.map +1 -0
  23. package/dist/ooxml/parts.d.ts +5 -0
  24. package/dist/ooxml/parts.d.ts.map +1 -0
  25. package/dist/ooxml/relationships.d.ts +21 -0
  26. package/dist/ooxml/relationships.d.ts.map +1 -0
  27. package/dist/ooxml/styles.d.ts +7 -0
  28. package/dist/ooxml/styles.d.ts.map +1 -0
  29. package/dist/ooxml/workbook.d.ts +3 -0
  30. package/dist/ooxml/workbook.d.ts.map +1 -0
  31. package/dist/ooxml/xml.d.ts +12 -0
  32. package/dist/ooxml/xml.d.ts.map +1 -0
  33. package/dist/options.d.ts +5 -0
  34. package/dist/options.d.ts.map +1 -0
  35. package/dist/types.d.ts +28 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/zip/central-directory.d.ts +7 -0
  38. package/dist/zip/central-directory.d.ts.map +1 -0
  39. package/package.json +64 -44
  40. package/index.cjs +0 -3
  41. package/index.d.ts +0 -62
  42. package/index.js +0 -7
  43. package/src/index.cjs +0 -894
package/src/index.cjs DELETED
@@ -1,894 +0,0 @@
1
- "use strict";
2
-
3
- const { randomUUID } = require("node:crypto");
4
- const fs = require("node:fs/promises");
5
- const { posix: packagePath, resolve: resolveFilePath, dirname } = require("node:path");
6
- const JSZip = require("jszip");
7
-
8
- const DRAWING_RELATIONSHIP_SUFFIXES = [
9
- "/comments",
10
- "/control",
11
- "/drawing",
12
- "/image",
13
- "/oleObject",
14
- "/threadedComment",
15
- "/vmlDrawing",
16
- ];
17
- const PERSON_RELATIONSHIP_SUFFIXES = ["/person"];
18
- const DRAWING_ELEMENTS = [
19
- "controls",
20
- "drawing",
21
- "legacyDrawing",
22
- "legacyDrawingHF",
23
- "oleObjects",
24
- "picture",
25
- ];
26
-
27
- const ERROR_MESSAGES = Object.freeze({
28
- ERR_INVALID_ARGUMENT: "Invalid argument",
29
- ERR_UNSUPPORTED_EXTENSION: "Only .xlsx files are supported",
30
- ERR_INVALID_XLSX: "Invalid or unsupported XLSX workbook",
31
- ERR_LIMIT_EXCEEDED: "XLSX workbook exceeds a configured safety limit",
32
- ERR_WORKSHEET_COUNT: "The workbook must contain exactly one worksheet",
33
- ERR_SAME_PATH: "Input and output paths must be different",
34
- ERR_OUTPUT_EXISTS: "The output file already exists",
35
- ERR_FILE_READ: "Unable to read the input XLSX file",
36
- ERR_FILE_WRITE: "Unable to write the sanitized XLSX file",
37
- });
38
-
39
- const DEFAULT_LIMITS = Object.freeze({
40
- maxEntryCount: 10_000,
41
- maxUncompressedBytes: 512 * 1024 * 1024,
42
- maxXmlBytes: 512 * 1024 * 1024,
43
- maxWorksheetXmlBytes: 256 * 1024 * 1024,
44
- maxStylesXmlBytes: 64 * 1024 * 1024,
45
- maxMetadataXmlBytes: 16 * 1024 * 1024,
46
- maxRelationshipsXmlBytes: 8 * 1024 * 1024,
47
- });
48
-
49
- const SANITIZE_OPTION_NAMES = new Set(["requireSingleWorksheet", "limits"]);
50
- const FILE_OPTION_NAMES = new Set([
51
- "requireSingleWorksheet",
52
- "limits",
53
- "overwrite",
54
- ]);
55
- const LIMIT_NAMES = new Set(Object.keys(DEFAULT_LIMITS));
56
-
57
- class ExcelSanitizationError extends Error {
58
- constructor(code, message, options = {}) {
59
- super(message, options.cause === undefined ? undefined : { cause: options.cause });
60
- this.name = "ExcelSanitizationError";
61
- this.code = code;
62
- if (options.details !== undefined) {
63
- this.details = Object.freeze({ ...options.details });
64
- }
65
- }
66
- }
67
-
68
- function createError(code, options = {}) {
69
- return new ExcelSanitizationError(
70
- code,
71
- options.message ?? ERROR_MESSAGES[code] ?? "Excel sanitization failed",
72
- options,
73
- );
74
- }
75
-
76
- function isRecord(value) {
77
- return value !== null && typeof value === "object" && !Array.isArray(value);
78
- }
79
-
80
- function validateKnownKeys(value, knownKeys, label) {
81
- for (const key of Object.keys(value)) {
82
- if (!knownKeys.has(key)) {
83
- throw createError("ERR_INVALID_ARGUMENT", {
84
- message: `Unknown ${label}: ${key}`,
85
- details: { key },
86
- });
87
- }
88
- }
89
- }
90
-
91
- function normalizeLimits(overrides) {
92
- if (overrides === undefined) {
93
- return DEFAULT_LIMITS;
94
- }
95
- if (!isRecord(overrides)) {
96
- throw createError("ERR_INVALID_ARGUMENT", {
97
- message: "options.limits must be an object",
98
- });
99
- }
100
- validateKnownKeys(overrides, LIMIT_NAMES, "limit");
101
- const limits = { ...DEFAULT_LIMITS };
102
- for (const [name, value] of Object.entries(overrides)) {
103
- if (!Number.isSafeInteger(value) || value <= 0) {
104
- throw createError("ERR_INVALID_ARGUMENT", {
105
- message: `options.limits.${name} must be a positive safe integer`,
106
- details: { limit: name, value },
107
- });
108
- }
109
- limits[name] = value;
110
- }
111
- return Object.freeze(limits);
112
- }
113
-
114
- function normalizeOptions(options, fileOptions = false) {
115
- if (options === undefined) {
116
- return {
117
- requireSingleWorksheet: false,
118
- limits: DEFAULT_LIMITS,
119
- ...(fileOptions ? { overwrite: false } : {}),
120
- };
121
- }
122
- if (!isRecord(options)) {
123
- throw createError("ERR_INVALID_ARGUMENT", {
124
- message: "options must be an object",
125
- });
126
- }
127
- validateKnownKeys(
128
- options,
129
- fileOptions ? FILE_OPTION_NAMES : SANITIZE_OPTION_NAMES,
130
- "option",
131
- );
132
- if (
133
- options.requireSingleWorksheet !== undefined &&
134
- typeof options.requireSingleWorksheet !== "boolean"
135
- ) {
136
- throw createError("ERR_INVALID_ARGUMENT", {
137
- message: "options.requireSingleWorksheet must be a boolean",
138
- });
139
- }
140
- if (
141
- fileOptions &&
142
- options.overwrite !== undefined &&
143
- typeof options.overwrite !== "boolean"
144
- ) {
145
- throw createError("ERR_INVALID_ARGUMENT", {
146
- message: "options.overwrite must be a boolean",
147
- });
148
- }
149
- return {
150
- requireSingleWorksheet: options.requireSingleWorksheet ?? false,
151
- limits: normalizeLimits(options.limits),
152
- ...(fileOptions ? { overwrite: options.overwrite ?? false } : {}),
153
- };
154
- }
155
-
156
- function isXlsxFilename(name) {
157
- return typeof name === "string" && name.toLowerCase().endsWith(".xlsx");
158
- }
159
-
160
- async function sanitizeExcel(buffer, options) {
161
- if (!Buffer.isBuffer(buffer)) {
162
- throw createError("ERR_INVALID_ARGUMENT", {
163
- message: "buffer must be a Node.js Buffer",
164
- });
165
- }
166
- const normalized = normalizeOptions(options);
167
- try {
168
- return await sanitizeOoxmlWorkbook(buffer, normalized);
169
- } catch (error) {
170
- if (error instanceof ExcelSanitizationError) {
171
- throw error;
172
- }
173
- throw createError("ERR_INVALID_XLSX", { cause: error });
174
- }
175
- }
176
-
177
- async function sanitizeExcelFile(inputPath, outputPath, options) {
178
- if (
179
- typeof inputPath !== "string" ||
180
- inputPath.length === 0 ||
181
- typeof outputPath !== "string" ||
182
- outputPath.length === 0
183
- ) {
184
- throw createError("ERR_INVALID_ARGUMENT", {
185
- message: "inputPath and outputPath must be non-empty strings",
186
- });
187
- }
188
- if (!isXlsxFilename(inputPath) || !isXlsxFilename(outputPath)) {
189
- throw createError("ERR_UNSUPPORTED_EXTENSION", {
190
- details: { inputPath, outputPath },
191
- });
192
- }
193
- const normalized = normalizeOptions(options, true);
194
- const absoluteInputPath = resolveFilePath(inputPath);
195
- const absoluteOutputPath = resolveFilePath(outputPath);
196
- if (absoluteInputPath === absoluteOutputPath) {
197
- throw createError("ERR_SAME_PATH", {
198
- details: { inputPath: absoluteInputPath, outputPath: absoluteOutputPath },
199
- });
200
- }
201
-
202
- let input;
203
- try {
204
- input = await fs.readFile(absoluteInputPath);
205
- } catch (error) {
206
- throw createError("ERR_FILE_READ", {
207
- cause: error,
208
- details: { inputPath: absoluteInputPath },
209
- });
210
- }
211
-
212
- const sanitized = await sanitizeExcel(input, {
213
- requireSingleWorksheet: normalized.requireSingleWorksheet,
214
- limits: normalized.limits,
215
- });
216
-
217
- if (!normalized.overwrite) {
218
- try {
219
- await fs.writeFile(absoluteOutputPath, sanitized, { flag: "wx" });
220
- return;
221
- } catch (error) {
222
- if (error?.code === "EEXIST") {
223
- throw createError("ERR_OUTPUT_EXISTS", {
224
- cause: error,
225
- details: { outputPath: absoluteOutputPath },
226
- });
227
- }
228
- await fs.unlink(absoluteOutputPath).catch(() => undefined);
229
- throw createError("ERR_FILE_WRITE", {
230
- cause: error,
231
- details: { outputPath: absoluteOutputPath },
232
- });
233
- }
234
- }
235
-
236
- const temporaryPath = `${absoluteOutputPath}.${process.pid}.${randomUUID()}.tmp`;
237
- try {
238
- await fs.writeFile(temporaryPath, sanitized, { flag: "wx" });
239
- await fs.rename(temporaryPath, absoluteOutputPath);
240
- } catch (error) {
241
- await fs.unlink(temporaryPath).catch(() => undefined);
242
- throw createError("ERR_FILE_WRITE", {
243
- cause: error,
244
- details: { outputPath: absoluteOutputPath },
245
- });
246
- }
247
- }
248
-
249
- async function sanitizeOoxmlWorkbook(buffer, options) {
250
- const { limits, requireSingleWorksheet } = options;
251
- if (!hasZipSignature(buffer)) {
252
- throw new Error("Invalid OOXML ZIP container");
253
- }
254
- validateZipCentralDirectory(buffer, limits);
255
- const readBudget = { consumedBytes: 0, limitBytes: limits.maxXmlBytes };
256
-
257
- // CRC verification eagerly inflates every archive entry. Required XML parts
258
- // are instead inflated lazily below and constrained by per-part budgets.
259
- const zip = await JSZip.loadAsync(buffer);
260
- const workbookXml = await requireXml(
261
- zip,
262
- "xl/workbook.xml",
263
- readBudget,
264
- limits.maxMetadataXmlBytes,
265
- );
266
- const workbookRelsPath = "xl/_rels/workbook.xml.rels";
267
- const workbookRelsXml = await requireXml(
268
- zip,
269
- workbookRelsPath,
270
- readBudget,
271
- limits.maxRelationshipsXmlBytes,
272
- );
273
- const workbookRels = parseRelationships(workbookRelsXml, "xl/workbook.xml");
274
- await requireXml(
275
- zip,
276
- "[Content_Types].xml",
277
- readBudget,
278
- limits.maxMetadataXmlBytes,
279
- );
280
-
281
- const sheetTags = workbookXml.match(/<(?:\w+:)?sheet\b[^>]*\/?>/gi) ?? [];
282
- enforceWorksheetCount(sheetTags.length, requireSingleWorksheet);
283
- if (sheetTags.length === 0) {
284
- throw new Error("Workbook contains no sheets");
285
- }
286
- const relsById = new Map(workbookRels.map((relationship) => [relationship.id, relationship]));
287
- const worksheetPaths = sheetTags.flatMap((sheetTag) => {
288
- const id = getAttribute(sheetTag, "r:id");
289
- const relationship = id ? relsById.get(id) : undefined;
290
- return relationship?.type.endsWith("/worksheet")
291
- ? [relationship.target]
292
- : [];
293
- });
294
-
295
- const detachedRoots = new Set();
296
- const cleanedWorkbookRels = removeRelationships(
297
- workbookRelsXml,
298
- "xl/workbook.xml",
299
- PERSON_RELATIONSHIP_SUFFIXES,
300
- );
301
- zip.file(workbookRelsPath, cleanedWorkbookRels.xml);
302
- for (const target of cleanedWorkbookRels.targets) {
303
- detachedRoots.add(target);
304
- }
305
-
306
- const worksheetXmlByPath = new Map();
307
- for (const worksheetPath of worksheetPaths) {
308
- const worksheetXml = await requireXml(
309
- zip,
310
- worksheetPath,
311
- readBudget,
312
- limits.maxWorksheetXmlBytes,
313
- );
314
- const cleanedWorksheetXml = removeThreadedCommentExtensions(
315
- removeWorksheetDrawingMarkup(worksheetXml),
316
- );
317
- worksheetXmlByPath.set(worksheetPath, cleanedWorksheetXml);
318
- zip.file(worksheetPath, cleanedWorksheetXml);
319
-
320
- const relationshipsPath = relationshipFileForPart(worksheetPath);
321
- const relationshipsFile = zip.file(relationshipsPath);
322
- if (relationshipsFile) {
323
- const cleaned = removeRelationships(
324
- await readXmlPart(
325
- relationshipsFile,
326
- relationshipsPath,
327
- readBudget,
328
- limits.maxRelationshipsXmlBytes,
329
- ),
330
- worksheetPath,
331
- DRAWING_RELATIONSHIP_SUFFIXES,
332
- );
333
- zip.file(relationshipsPath, cleaned.xml);
334
- for (const target of cleaned.targets) {
335
- detachedRoots.add(target);
336
- }
337
- }
338
- }
339
-
340
- await removeUnreachableParts(zip, detachedRoots, readBudget, limits);
341
- await removeUnusedCellStyles(zip, worksheetXmlByPath, readBudget, limits);
342
- return zip.generateAsync({
343
- type: "nodebuffer",
344
- compression: "DEFLATE",
345
- compressionOptions: { level: 6 },
346
- });
347
- }
348
-
349
- function hasZipSignature(buffer) {
350
- return buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b;
351
- }
352
-
353
- function validateZipCentralDirectory(buffer, limits) {
354
- const endOffset = findEndOfCentralDirectory(buffer);
355
- if (endOffset === undefined || endOffset + 22 > buffer.length) {
356
- throw new Error("ZIP end-of-central-directory record is missing");
357
- }
358
- const diskNumber = buffer.readUInt16LE(endOffset + 4);
359
- const centralDirectoryDisk = buffer.readUInt16LE(endOffset + 6);
360
- const entriesOnDisk = buffer.readUInt16LE(endOffset + 8);
361
- const totalEntries = buffer.readUInt16LE(endOffset + 10);
362
- const centralDirectorySize = buffer.readUInt32LE(endOffset + 12);
363
- const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16);
364
- const commentLength = buffer.readUInt16LE(endOffset + 20);
365
- if (endOffset + 22 + commentLength > buffer.length) {
366
- throw new Error("Truncated ZIP comment");
367
- }
368
- if (
369
- diskNumber !== 0 ||
370
- centralDirectoryDisk !== 0 ||
371
- entriesOnDisk !== totalEntries
372
- ) {
373
- throw new Error("Multi-disk ZIP files are not supported");
374
- }
375
- if (
376
- totalEntries === 0xffff ||
377
- centralDirectorySize === 0xffffffff ||
378
- centralDirectoryOffset === 0xffffffff ||
379
- (endOffset >= 20 && buffer.readUInt32LE(endOffset - 20) === 0x07064b50)
380
- ) {
381
- throw new Error("ZIP64 files are not supported");
382
- }
383
- if (totalEntries > limits.maxEntryCount) {
384
- throw limitExceeded("maxEntryCount", limits.maxEntryCount, totalEntries);
385
- }
386
-
387
- const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize;
388
- if (
389
- centralDirectoryOffset > endOffset ||
390
- centralDirectoryEnd > endOffset ||
391
- centralDirectoryEnd > buffer.length
392
- ) {
393
- throw new Error("Invalid ZIP central-directory bounds");
394
- }
395
-
396
- let entryOffset = centralDirectoryOffset;
397
- let totalUncompressedSize = 0;
398
- for (let index = 0; index < totalEntries; index += 1) {
399
- if (
400
- entryOffset + 46 > centralDirectoryEnd ||
401
- buffer.readUInt32LE(entryOffset) !== 0x02014b50
402
- ) {
403
- throw new Error("Malformed ZIP central-directory entry");
404
- }
405
- const compressedSize = buffer.readUInt32LE(entryOffset + 20);
406
- const uncompressedSize = buffer.readUInt32LE(entryOffset + 24);
407
- const fileNameLength = buffer.readUInt16LE(entryOffset + 28);
408
- const extraLength = buffer.readUInt16LE(entryOffset + 30);
409
- const fileCommentLength = buffer.readUInt16LE(entryOffset + 32);
410
- const entryDisk = buffer.readUInt16LE(entryOffset + 34);
411
- const localHeaderOffset = buffer.readUInt32LE(entryOffset + 42);
412
- if (
413
- compressedSize === 0xffffffff ||
414
- uncompressedSize === 0xffffffff ||
415
- localHeaderOffset === 0xffffffff ||
416
- entryDisk !== 0
417
- ) {
418
- throw new Error("ZIP64 or multi-disk entries are not supported");
419
- }
420
-
421
- const fileNameOffset = entryOffset + 46;
422
- const extraOffset = fileNameOffset + fileNameLength;
423
- const nextEntryOffset = extraOffset + extraLength + fileCommentLength;
424
- if (nextEntryOffset > centralDirectoryEnd) {
425
- throw new Error("Truncated ZIP central-directory entry");
426
- }
427
- validatePackageEntryName(
428
- buffer.subarray(fileNameOffset, extraOffset).toString("utf8"),
429
- );
430
- rejectZip64ExtraFields(buffer, extraOffset, extraLength);
431
- if (localHeaderOffset >= centralDirectoryOffset) {
432
- throw new Error("Invalid ZIP local-header offset");
433
- }
434
-
435
- totalUncompressedSize += uncompressedSize;
436
- if (totalUncompressedSize > limits.maxUncompressedBytes) {
437
- throw limitExceeded(
438
- "maxUncompressedBytes",
439
- limits.maxUncompressedBytes,
440
- totalUncompressedSize,
441
- );
442
- }
443
- entryOffset = nextEntryOffset;
444
- }
445
- if (entryOffset !== centralDirectoryEnd) {
446
- throw new Error("ZIP central-directory entry count is inconsistent");
447
- }
448
- }
449
-
450
- function validatePackageEntryName(name) {
451
- const normalized = name.replaceAll("\\", "/");
452
- if (
453
- normalized.includes("\0") ||
454
- normalized.startsWith("/") ||
455
- /^[A-Za-z]:\//.test(normalized) ||
456
- normalized.split("/").includes("..")
457
- ) {
458
- throw new Error(`Unsafe ZIP entry name: ${name}`);
459
- }
460
- }
461
-
462
- function findEndOfCentralDirectory(buffer) {
463
- const minimumOffset = Math.max(0, buffer.length - 65_557);
464
- for (let offset = buffer.length - 22; offset >= minimumOffset; offset -= 1) {
465
- if (
466
- buffer.readUInt32LE(offset) === 0x06054b50 &&
467
- offset + 22 + buffer.readUInt16LE(offset + 20) === buffer.length
468
- ) {
469
- return offset;
470
- }
471
- }
472
- return undefined;
473
- }
474
-
475
- function rejectZip64ExtraFields(buffer, extraOffset, extraLength) {
476
- const extraEnd = extraOffset + extraLength;
477
- let offset = extraOffset;
478
- while (offset < extraEnd) {
479
- if (offset + 4 > extraEnd) {
480
- throw new Error("Malformed ZIP extra field");
481
- }
482
- const headerId = buffer.readUInt16LE(offset);
483
- const fieldLength = buffer.readUInt16LE(offset + 2);
484
- offset += 4;
485
- if (offset + fieldLength > extraEnd) {
486
- throw new Error("Truncated ZIP extra field");
487
- }
488
- if (headerId === 0x0001) {
489
- throw new Error("ZIP64 files are not supported");
490
- }
491
- offset += fieldLength;
492
- }
493
- }
494
-
495
- function limitExceeded(limit, configured, actual, partName) {
496
- return createError("ERR_LIMIT_EXCEEDED", {
497
- details: {
498
- limit,
499
- configured,
500
- actual,
501
- ...(partName === undefined ? {} : { partName }),
502
- },
503
- });
504
- }
505
-
506
- function enforceWorksheetCount(count, required) {
507
- if (required && count !== 1) {
508
- throw createError("ERR_WORKSHEET_COUNT", {
509
- details: { expected: 1, actual: count },
510
- });
511
- }
512
- }
513
-
514
- async function requireXml(zip, partName, budget, partLimitBytes) {
515
- const file = zip.file(partName);
516
- if (!file) {
517
- throw new Error(`Required workbook part is missing: ${partName}`);
518
- }
519
- return readXmlPart(file, partName, budget, partLimitBytes);
520
- }
521
-
522
- function readXmlPart(file, partName, budget, partLimitBytes) {
523
- return new Promise((resolve, reject) => {
524
- const chunks = [];
525
- const stream = file.internalStream("nodebuffer");
526
- let partBytes = 0;
527
- let settled = false;
528
-
529
- const fail = (error) => {
530
- if (settled) {
531
- return;
532
- }
533
- settled = true;
534
- stream.pause();
535
- reject(error);
536
- };
537
-
538
- stream
539
- .on("data", (chunk) => {
540
- if (settled) {
541
- return;
542
- }
543
- partBytes += chunk.length;
544
- budget.consumedBytes += chunk.length;
545
- if (partBytes > partLimitBytes) {
546
- fail(limitExceeded("partBytes", partLimitBytes, partBytes, partName));
547
- return;
548
- }
549
- if (budget.consumedBytes > budget.limitBytes) {
550
- fail(
551
- limitExceeded(
552
- "maxXmlBytes",
553
- budget.limitBytes,
554
- budget.consumedBytes,
555
- partName,
556
- ),
557
- );
558
- return;
559
- }
560
- chunks.push(Buffer.from(chunk));
561
- })
562
- .on("error", (error) => fail(error))
563
- .on("end", () => {
564
- if (settled) {
565
- return;
566
- }
567
- settled = true;
568
- resolve(Buffer.concat(chunks, partBytes).toString("utf8"));
569
- })
570
- .resume();
571
- });
572
- }
573
-
574
- function removeWorksheetDrawingMarkup(xml) {
575
- return DRAWING_ELEMENTS.reduce((result, name) => {
576
- const element = new RegExp(
577
- `<(?:\\w+:)?${name}\\b[^>]*(?:\\/\\s*>|>[\\s\\S]*?<\\/(?:\\w+:)?${name}\\s*>)`,
578
- "gi",
579
- );
580
- return result.replace(element, "");
581
- }, xml);
582
- }
583
-
584
- function removeThreadedCommentExtensions(xml) {
585
- return xml.replace(
586
- /<(?:\w+:)?ext\b[^>]*>[\s\S]*?<\/(?:\w+:)?ext\s*>/gi,
587
- (extension) =>
588
- /<(?:\w+:)?threadedComments?\b/i.test(extension) ? "" : extension,
589
- );
590
- }
591
-
592
- function removeRelationships(xml, source, relationshipSuffixes) {
593
- const targets = new Set();
594
- const cleaned = xml.replace(
595
- /<(?:\w+:)?Relationship\b[^>]*?(?:\/\s*>|>\s*<\/(?:\w+:)?Relationship\s*>)/gi,
596
- (tag) => {
597
- const type = getAttribute(tag, "Type") ?? "";
598
- const target = getAttribute(tag, "Target");
599
- const targetMode = getAttribute(tag, "TargetMode");
600
- if (
601
- !target ||
602
- targetMode?.toLowerCase() === "external" ||
603
- !relationshipSuffixes.some((suffix) => type.endsWith(suffix))
604
- ) {
605
- return tag;
606
- }
607
- targets.add(resolveTarget(source, target));
608
- return "";
609
- },
610
- );
611
- return { xml: cleaned, targets };
612
- }
613
-
614
- function parseRelationships(xml, source) {
615
- return Array.from(
616
- xml.matchAll(
617
- /<(?:\w+:)?Relationship\b([^>]*?)(?:\/\s*>|>\s*<\/(?:\w+:)?Relationship\s*>)/gi,
618
- ),
619
- ).flatMap((match) => {
620
- const attributes = match[1];
621
- const id = getAttribute(attributes, "Id");
622
- const target = getAttribute(attributes, "Target");
623
- const type = getAttribute(attributes, "Type");
624
- if (!id || !target || !type) {
625
- return [];
626
- }
627
- const targetMode = getAttribute(attributes, "TargetMode");
628
- return [
629
- {
630
- id,
631
- target:
632
- targetMode?.toLowerCase() === "external"
633
- ? target
634
- : resolveTarget(source, target),
635
- targetMode,
636
- type,
637
- },
638
- ];
639
- });
640
- }
641
-
642
- function getAttribute(xml, name) {
643
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
644
- const match = xml.match(
645
- new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(["'])(.*?)\\1`, "i"),
646
- );
647
- return match ? decodeXml(match[2]) : undefined;
648
- }
649
-
650
- function decodeXml(value) {
651
- return value
652
- .replaceAll("&amp;", "&")
653
- .replaceAll("&quot;", '"')
654
- .replaceAll("&apos;", "'")
655
- .replaceAll("&lt;", "<")
656
- .replaceAll("&gt;", ">");
657
- }
658
-
659
- function resolveTarget(source, target) {
660
- const resolved = target.startsWith("/")
661
- ? packagePath.normalize(target.slice(1))
662
- : packagePath.normalize(packagePath.join(packagePath.dirname(source), target));
663
- if (resolved === ".." || resolved.startsWith("../")) {
664
- throw new Error(`Relationship target escapes package: ${target}`);
665
- }
666
- return resolved;
667
- }
668
-
669
- function relationshipFileForPart(partName) {
670
- return packagePath.join(
671
- packagePath.dirname(partName),
672
- "_rels",
673
- `${packagePath.basename(partName)}.rels`,
674
- );
675
- }
676
-
677
- function sourcePartForRelationshipFile(fileName) {
678
- if (fileName === "_rels/.rels") {
679
- return "";
680
- }
681
- const match = fileName.match(/^(.*\/)_rels\/([^/]+)\.rels$/);
682
- return match ? `${match[1]}${match[2]}` : undefined;
683
- }
684
-
685
- async function removeUnreachableParts(zip, detachedRoots, readBudget, limits) {
686
- if (detachedRoots.size === 0) {
687
- return;
688
- }
689
- const graph = new Map();
690
- for (const fileName of Object.keys(zip.files)) {
691
- if (!fileName.endsWith(".rels")) {
692
- continue;
693
- }
694
- const source = sourcePartForRelationshipFile(fileName);
695
- const file = zip.file(fileName);
696
- if (source !== undefined && file) {
697
- graph.set(
698
- source,
699
- parseRelationships(
700
- await readXmlPart(
701
- file,
702
- fileName,
703
- readBudget,
704
- limits.maxRelationshipsXmlBytes,
705
- ),
706
- source,
707
- ),
708
- );
709
- }
710
- }
711
-
712
- const candidates = collectReachableParts(detachedRoots, graph);
713
- const packageReachable = collectReachableParts(new Set([""]), graph);
714
- packageReachable.delete("");
715
- const deletedParts = new Set(
716
- [...candidates].filter((partName) => !packageReachable.has(partName)),
717
- );
718
- for (const partName of deletedParts) {
719
- zip.remove(partName);
720
- zip.remove(relationshipFileForPart(partName));
721
- }
722
-
723
- const contentTypes = await requireXml(
724
- zip,
725
- "[Content_Types].xml",
726
- readBudget,
727
- limits.maxMetadataXmlBytes,
728
- );
729
- zip.file(
730
- "[Content_Types].xml",
731
- contentTypes.replace(/<(?:\w+:)?Override\b[^>]*\/>/gi, (tag) => {
732
- const partName = getAttribute(tag, "PartName")?.replace(/^\//, "");
733
- return partName && deletedParts.has(partName) ? "" : tag;
734
- }),
735
- );
736
- }
737
-
738
- function collectReachableParts(initial, graph) {
739
- const reachable = new Set(initial);
740
- const pending = [...initial];
741
- while (pending.length > 0) {
742
- const source = pending.pop();
743
- if (source === undefined) {
744
- continue;
745
- }
746
- for (const relationship of graph.get(source) ?? []) {
747
- if (
748
- relationship.targetMode?.toLowerCase() !== "external" &&
749
- !reachable.has(relationship.target)
750
- ) {
751
- reachable.add(relationship.target);
752
- pending.push(relationship.target);
753
- }
754
- }
755
- }
756
- return reachable;
757
- }
758
-
759
- async function removeUnusedCellStyles(zip, worksheets, readBudget, limits) {
760
- const stylesFile = zip.file("xl/styles.xml");
761
- if (!stylesFile) {
762
- return;
763
- }
764
- const usedStyles = new Set([0]);
765
- for (const xml of worksheets.values()) {
766
- collectStyleReferences(xml, usedStyles);
767
- }
768
-
769
- const stylesXml = await readXmlPart(
770
- stylesFile,
771
- "xl/styles.xml",
772
- readBudget,
773
- limits.maxStylesXmlBytes,
774
- );
775
- const cellXfs = findCollection(stylesXml, "cellXfs");
776
- if (!cellXfs) {
777
- return;
778
- }
779
- const xfs = matchElements(cellXfs.body, "xf");
780
- const retainedIndexes = [...usedStyles].sort((left, right) => left - right);
781
- if (
782
- retainedIndexes.some(
783
- (index) => !Number.isInteger(index) || index < 0 || index >= xfs.length,
784
- )
785
- ) {
786
- throw new Error(
787
- `Worksheet references an invalid style index (${retainedIndexes.join(",")} of ${xfs.length})`,
788
- );
789
- }
790
- const indexMap = new Map(
791
- retainedIndexes.map((oldIndex, newIndex) => [oldIndex, newIndex]),
792
- );
793
- zip.file(
794
- "xl/styles.xml",
795
- replaceCollection(
796
- stylesXml,
797
- "cellXfs",
798
- retainedIndexes.map((index) => xfs[index]).join(""),
799
- retainedIndexes.length,
800
- ),
801
- );
802
- for (const [worksheetPath, xml] of worksheets) {
803
- zip.file(worksheetPath, remapWorksheetStyles(xml, indexMap));
804
- }
805
- }
806
-
807
- function collectStyleReferences(xml, references) {
808
- for (const tag of xml.match(/<(?:\w+:)?(?:c|row)\b[^>]*>/gi) ?? []) {
809
- const value = getAttribute(tag, "s");
810
- if (value !== undefined) {
811
- references.add(Number(value));
812
- }
813
- }
814
- for (const tag of xml.match(/<(?:\w+:)?col\b[^>]*>/gi) ?? []) {
815
- const value = getAttribute(tag, "style");
816
- if (value !== undefined) {
817
- references.add(Number(value));
818
- }
819
- }
820
- }
821
-
822
- function remapWorksheetStyles(xml, indexMap) {
823
- return xml
824
- .replace(/<(?:\w+:)?(?:c|row)\b[^>]*>/gi, (tag) =>
825
- remapNumericAttribute(tag, "s", indexMap),
826
- )
827
- .replace(/<(?:\w+:)?col\b[^>]*>/gi, (tag) =>
828
- remapNumericAttribute(tag, "style", indexMap),
829
- );
830
- }
831
-
832
- function remapNumericAttribute(tag, attributeName, indexMap) {
833
- const expression = new RegExp(
834
- `(\\s${attributeName}\\s*=\\s*)(["'])(\\d+)\\2`,
835
- "i",
836
- );
837
- return tag.replace(expression, (match, prefix, quote, value) => {
838
- const remapped = indexMap.get(Number(value));
839
- return remapped === undefined
840
- ? match
841
- : `${prefix}${quote}${remapped}${quote}`;
842
- });
843
- }
844
-
845
- function findCollection(xml, name) {
846
- const match = xml.match(
847
- new RegExp(
848
- `<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)<\\/(?:\\w+:)?${name}\\s*>`,
849
- "i",
850
- ),
851
- );
852
- if (!match) {
853
- return undefined;
854
- }
855
- return {
856
- body: match[1],
857
- full: match[0],
858
- opening: match[0].slice(0, match[0].indexOf(">") + 1),
859
- };
860
- }
861
-
862
- function replaceCollection(xml, name, body, count) {
863
- const collection = findCollection(xml, name);
864
- if (!collection) {
865
- return xml;
866
- }
867
- const opening = /\bcount\s*=/.test(collection.opening)
868
- ? collection.opening.replace(
869
- /(\bcount\s*=\s*)(["'])\d+\2/i,
870
- `$1"${count}"`,
871
- )
872
- : collection.opening.replace(/>$/, ` count="${count}">`);
873
- const prefix = collection.opening.match(/^<(\w+:)/)?.[1] ?? "";
874
- return xml.replace(collection.full, `${opening}${body}</${prefix}${name}>`);
875
- }
876
-
877
- function matchElements(xml, name) {
878
- return (
879
- xml.match(
880
- new RegExp(
881
- `<(?:\\w+:)?${name}\\b[^>]*\\/\\s*>|<(?:\\w+:)?${name}\\b[^>]*>[\\s\\S]*?<\\/(?:\\w+:)?${name}\\s*>`,
882
- "gi",
883
- ),
884
- ) ?? []
885
- );
886
- }
887
-
888
- module.exports = {
889
- DEFAULT_LIMITS,
890
- ExcelSanitizationError,
891
- isXlsxFilename,
892
- sanitizeExcel,
893
- sanitizeExcelFile,
894
- };