@opengeni/artifact-tool 0.2.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 (203) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +104 -0
  3. package/dist/bounded-zip.d.ts +28 -0
  4. package/dist/chunk-4BSENMAE.js +39 -0
  5. package/dist/chunk-4BSENMAE.js.map +1 -0
  6. package/dist/chunk-4FRFNNBM.js +276 -0
  7. package/dist/chunk-4FRFNNBM.js.map +1 -0
  8. package/dist/chunk-5XIA6KHZ.js +49 -0
  9. package/dist/chunk-5XIA6KHZ.js.map +1 -0
  10. package/dist/chunk-66CT2ZRP.js +2229 -0
  11. package/dist/chunk-66CT2ZRP.js.map +1 -0
  12. package/dist/chunk-72SYDU4O.js +319 -0
  13. package/dist/chunk-72SYDU4O.js.map +1 -0
  14. package/dist/chunk-B2ZJRZDA.js +279 -0
  15. package/dist/chunk-B2ZJRZDA.js.map +1 -0
  16. package/dist/chunk-DCTLTNPW.js +2458 -0
  17. package/dist/chunk-DCTLTNPW.js.map +1 -0
  18. package/dist/chunk-FGZM4FQ6.js +473 -0
  19. package/dist/chunk-FGZM4FQ6.js.map +1 -0
  20. package/dist/chunk-HTJBLBDE.js +591 -0
  21. package/dist/chunk-HTJBLBDE.js.map +1 -0
  22. package/dist/chunk-IYFXZQ34.js +5306 -0
  23. package/dist/chunk-IYFXZQ34.js.map +1 -0
  24. package/dist/chunk-J6N7IR44.js +172 -0
  25. package/dist/chunk-J6N7IR44.js.map +1 -0
  26. package/dist/chunk-N2IHNWBS.js +1002 -0
  27. package/dist/chunk-N2IHNWBS.js.map +1 -0
  28. package/dist/chunk-NAG44K67.js +549 -0
  29. package/dist/chunk-NAG44K67.js.map +1 -0
  30. package/dist/chunk-O24KTEGP.js +33 -0
  31. package/dist/chunk-O24KTEGP.js.map +1 -0
  32. package/dist/chunk-PI2AZVGK.js +2074 -0
  33. package/dist/chunk-PI2AZVGK.js.map +1 -0
  34. package/dist/chunk-Q2FFHEY7.js +40 -0
  35. package/dist/chunk-Q2FFHEY7.js.map +1 -0
  36. package/dist/chunk-RBGQADXA.js +57 -0
  37. package/dist/chunk-RBGQADXA.js.map +1 -0
  38. package/dist/chunk-U5G2BKM2.js +613 -0
  39. package/dist/chunk-U5G2BKM2.js.map +1 -0
  40. package/dist/chunk-VOJHMSM3.js +39 -0
  41. package/dist/chunk-VOJHMSM3.js.map +1 -0
  42. package/dist/chunk-VUNV25KB.js +16 -0
  43. package/dist/chunk-VUNV25KB.js.map +1 -0
  44. package/dist/chunk-XIFUNVBO.js +101 -0
  45. package/dist/chunk-XIFUNVBO.js.map +1 -0
  46. package/dist/chunk-ZCJYRJGB.js +489 -0
  47. package/dist/chunk-ZCJYRJGB.js.map +1 -0
  48. package/dist/chunk-ZZXPEE4S.js +2260 -0
  49. package/dist/chunk-ZZXPEE4S.js.map +1 -0
  50. package/dist/document-docx-api.d.ts +49 -0
  51. package/dist/document-docx-codec.d.ts +9 -0
  52. package/dist/document-docx-codec.js +4765 -0
  53. package/dist/document-docx-codec.js.map +1 -0
  54. package/dist/document-docx-import.d.ts +261 -0
  55. package/dist/document-docx-packer.d.ts +18 -0
  56. package/dist/document-docx-state.d.ts +12 -0
  57. package/dist/document-render.d.ts +3 -0
  58. package/dist/document-render.js +492 -0
  59. package/dist/document-render.js.map +1 -0
  60. package/dist/document.d.ts +432 -0
  61. package/dist/document.js +37 -0
  62. package/dist/document.js.map +1 -0
  63. package/dist/errors.d.ts +23 -0
  64. package/dist/file-blob.d.ts +11 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +186 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/inspection.d.ts +12 -0
  69. package/dist/kernel.d.ts +101 -0
  70. package/dist/lib-GQLPACFK.js +9746 -0
  71. package/dist/lib-GQLPACFK.js.map +1 -0
  72. package/dist/materializer-cli-entry.d.ts +2 -0
  73. package/dist/materializer-cli-entry.js +29 -0
  74. package/dist/materializer-cli-entry.js.map +1 -0
  75. package/dist/materializer-cli.d.ts +24 -0
  76. package/dist/materializer-cli.js +24 -0
  77. package/dist/materializer-cli.js.map +1 -0
  78. package/dist/native-raster.d.ts +10 -0
  79. package/dist/native.d.ts +129 -0
  80. package/dist/native.js +31 -0
  81. package/dist/native.js.map +1 -0
  82. package/dist/office-import.d.ts +48 -0
  83. package/dist/office-import.js +154 -0
  84. package/dist/office-import.js.map +1 -0
  85. package/dist/presentation-pptx-api.d.ts +60 -0
  86. package/dist/presentation-pptx-import.d.ts +5 -0
  87. package/dist/presentation-pptx-state-digest-765CZZED.js +10 -0
  88. package/dist/presentation-pptx-state-digest-765CZZED.js.map +1 -0
  89. package/dist/presentation-pptx-state-digest.d.ts +3 -0
  90. package/dist/presentation-pptx-state.d.ts +4 -0
  91. package/dist/presentation-pptx-xml.d.ts +29 -0
  92. package/dist/presentation-pptx.d.ts +5 -0
  93. package/dist/presentation-pptx.js +2365 -0
  94. package/dist/presentation-pptx.js.map +1 -0
  95. package/dist/presentation-render.d.ts +29 -0
  96. package/dist/presentation-render.js +375 -0
  97. package/dist/presentation-render.js.map +1 -0
  98. package/dist/presentation.d.ts +565 -0
  99. package/dist/presentation.js +71 -0
  100. package/dist/presentation.js.map +1 -0
  101. package/dist/production-composite.d.ts +68 -0
  102. package/dist/production-document.d.ts +2 -0
  103. package/dist/production-document.js +176 -0
  104. package/dist/production-document.js.map +1 -0
  105. package/dist/production-native-codecs.d.ts +94 -0
  106. package/dist/production-presentation.d.ts +2 -0
  107. package/dist/production-presentation.js +176 -0
  108. package/dist/production-presentation.js.map +1 -0
  109. package/dist/production-sha256.d.ts +2 -0
  110. package/dist/production-spreadsheet-native.d.ts +12 -0
  111. package/dist/production-spreadsheet.d.ts +2 -0
  112. package/dist/production-spreadsheet.js +176 -0
  113. package/dist/production-spreadsheet.js.map +1 -0
  114. package/dist/production.d.ts +96 -0
  115. package/dist/raster-image.d.ts +34 -0
  116. package/dist/reference.d.ts +17 -0
  117. package/dist/reference.js +574 -0
  118. package/dist/reference.js.map +1 -0
  119. package/dist/runtime-cli-entry.d.ts +2 -0
  120. package/dist/runtime-cli-entry.js +21 -0
  121. package/dist/runtime-cli-entry.js.map +1 -0
  122. package/dist/runtime-cli.d.ts +76 -0
  123. package/dist/runtime-cli.js +26 -0
  124. package/dist/runtime-cli.js.map +1 -0
  125. package/dist/runtime-development.d.ts +73 -0
  126. package/dist/runtime-development.js +30 -0
  127. package/dist/runtime-development.js.map +1 -0
  128. package/dist/runtime-receipt.d.ts +19 -0
  129. package/dist/runtime.d.ts +355 -0
  130. package/dist/runtime.js +42 -0
  131. package/dist/runtime.js.map +1 -0
  132. package/dist/snapshot.d.ts +26 -0
  133. package/dist/snapshot.js +61 -0
  134. package/dist/snapshot.js.map +1 -0
  135. package/dist/spreadsheet-address.d.ts +18 -0
  136. package/dist/spreadsheet-file.d.ts +17 -0
  137. package/dist/spreadsheet-formula.d.ts +67 -0
  138. package/dist/spreadsheet-image.d.ts +17 -0
  139. package/dist/spreadsheet-render.d.ts +5 -0
  140. package/dist/spreadsheet-render.js +752 -0
  141. package/dist/spreadsheet-render.js.map +1 -0
  142. package/dist/spreadsheet-types.d.ts +155 -0
  143. package/dist/spreadsheet-xlsx-api.d.ts +88 -0
  144. package/dist/spreadsheet-xlsx-codec.d.ts +16 -0
  145. package/dist/spreadsheet-xlsx-codec.js +16 -0
  146. package/dist/spreadsheet-xlsx-codec.js.map +1 -0
  147. package/dist/spreadsheet-xlsx-state.d.ts +16 -0
  148. package/dist/spreadsheet.d.ts +724 -0
  149. package/dist/spreadsheet.js +67 -0
  150. package/dist/spreadsheet.js.map +1 -0
  151. package/package.json +129 -0
  152. package/src/bounded-zip.ts +450 -0
  153. package/src/document-docx-api.ts +64 -0
  154. package/src/document-docx-codec.ts +2253 -0
  155. package/src/document-docx-import.ts +3814 -0
  156. package/src/document-docx-packer.ts +72 -0
  157. package/src/document-docx-state.ts +59 -0
  158. package/src/document-render.ts +615 -0
  159. package/src/document.ts +2800 -0
  160. package/src/errors.ts +45 -0
  161. package/src/file-blob.ts +43 -0
  162. package/src/index.ts +4 -0
  163. package/src/inspection.ts +50 -0
  164. package/src/kernel.ts +525 -0
  165. package/src/materializer-cli-entry.ts +11 -0
  166. package/src/materializer-cli.ts +743 -0
  167. package/src/native-raster.ts +141 -0
  168. package/src/native.ts +390 -0
  169. package/src/office-import.ts +243 -0
  170. package/src/presentation-pptx-api.ts +107 -0
  171. package/src/presentation-pptx-import.ts +2065 -0
  172. package/src/presentation-pptx-state-digest.ts +46 -0
  173. package/src/presentation-pptx-state.ts +18 -0
  174. package/src/presentation-pptx-xml.ts +473 -0
  175. package/src/presentation-pptx.ts +447 -0
  176. package/src/presentation-render.ts +532 -0
  177. package/src/presentation.ts +2599 -0
  178. package/src/production-composite.ts +805 -0
  179. package/src/production-document.ts +2 -0
  180. package/src/production-native-codecs.ts +1141 -0
  181. package/src/production-presentation.ts +2 -0
  182. package/src/production-sha256.ts +150 -0
  183. package/src/production-spreadsheet-native.ts +686 -0
  184. package/src/production-spreadsheet.ts +2 -0
  185. package/src/production.ts +1022 -0
  186. package/src/raster-image.ts +586 -0
  187. package/src/reference.ts +17 -0
  188. package/src/runtime-cli-entry.ts +13 -0
  189. package/src/runtime-cli.ts +670 -0
  190. package/src/runtime-development.ts +689 -0
  191. package/src/runtime-receipt.ts +169 -0
  192. package/src/runtime.ts +1463 -0
  193. package/src/snapshot.ts +88 -0
  194. package/src/spreadsheet-address.ts +90 -0
  195. package/src/spreadsheet-file.ts +71 -0
  196. package/src/spreadsheet-formula.ts +1212 -0
  197. package/src/spreadsheet-image.ts +242 -0
  198. package/src/spreadsheet-render.ts +985 -0
  199. package/src/spreadsheet-types.ts +171 -0
  200. package/src/spreadsheet-xlsx-api.ts +123 -0
  201. package/src/spreadsheet-xlsx-codec.ts +3063 -0
  202. package/src/spreadsheet-xlsx-state.ts +191 -0
  203. package/src/spreadsheet.ts +5195 -0
@@ -0,0 +1,3063 @@
1
+ import type ExcelJS from "exceljs";
2
+ import { SaxesParser, type SaxesTagNS } from "saxes";
3
+
4
+ import {
5
+ inflateBoundedZipEntry,
6
+ parseBoundedZip,
7
+ verifyBoundedZipEntry,
8
+ type BoundedZipEntry,
9
+ type BoundedZipFailure,
10
+ } from "./bounded-zip";
11
+ import { FileBlob } from "./file-blob";
12
+ import {
13
+ formatCellAddress,
14
+ formatRangeAddress,
15
+ parseRangeAddress,
16
+ type RangeAddress,
17
+ } from "./spreadsheet-address";
18
+ import { normalizeSpreadsheetImageConfig } from "./spreadsheet-image";
19
+ import { validateSupportedFormula } from "./spreadsheet-formula";
20
+ import { boundedUtf8ByteLength, inspectRasterImage } from "./raster-image";
21
+ import { Workbook, type Worksheet } from "./spreadsheet";
22
+ import {
23
+ XLSX_MEDIA_TYPE,
24
+ SpreadsheetFidelityError,
25
+ SpreadsheetSecurityError,
26
+ type SpreadsheetFidelityIssue,
27
+ type SpreadsheetLossPreservationEnvelope,
28
+ type SpreadsheetOpaqueContent,
29
+ type SpreadsheetOpaqueContentType,
30
+ type SpreadsheetOpaqueRelationship,
31
+ type SpreadsheetXlsxExportOptions,
32
+ type SpreadsheetXlsxImportLimits,
33
+ type SpreadsheetXlsxImportOptions,
34
+ } from "./spreadsheet-xlsx-api";
35
+ import {
36
+ LOSS_PRESERVATION,
37
+ cloneOpaqueContent,
38
+ hasOpaqueContent,
39
+ opaqueContentIssues,
40
+ spreadsheetFidelityReport,
41
+ spreadsheetLossPreservationEnvelope,
42
+ } from "./spreadsheet-xlsx-state";
43
+ import type {
44
+ BorderConfig,
45
+ CellFormat,
46
+ CellValue,
47
+ FormulaErrorValue,
48
+ FormulaResult,
49
+ SpreadsheetImageConfig,
50
+ } from "./spreadsheet-types";
51
+
52
+ const DEFAULT_XLSX_IMPORT_LIMITS: SpreadsheetXlsxImportLimits = Object.freeze({
53
+ compressedBytes: 64 * 1024 * 1024,
54
+ expandedBytes: 512 * 1024 * 1024,
55
+ entryBytes: 128 * 1024 * 1024,
56
+ entries: 20_000,
57
+ compressionRatio: 100,
58
+ inspectedXmlBytes: 64 * 1024 * 1024,
59
+ xmlDepth: 128,
60
+ xmlAttributesPerElement: 256,
61
+ xmlAttributesPerPart: 100_000,
62
+ xmlAttributes: 500_000,
63
+ xmlElementsPerPart: 1_000_000,
64
+ xmlElements: 2_000_000,
65
+ xmlTextCharactersPerPart: 16 * 1024 * 1024,
66
+ xmlTextCharacters: 64 * 1024 * 1024,
67
+ worksheetCellsPerPart: 1_000_000,
68
+ worksheetCells: 1_000_000,
69
+ formulasPerPart: 250_000,
70
+ formulas: 1_000_000,
71
+ formulaBytesPerPart: 16 * 1024 * 1024,
72
+ formulaBytes: 64 * 1024 * 1024,
73
+ sharedStrings: 1_000_000,
74
+ cellStyles: 100_000,
75
+ styleRecords: 250_000,
76
+ commentsPerPart: 100_000,
77
+ comments: 100_000,
78
+ relationshipsPerPart: 4_096,
79
+ relationships: 20_000,
80
+ mediaEntries: 1_024,
81
+ mediaEntryBytes: 32 * 1024 * 1024,
82
+ mediaBytes: 128 * 1024 * 1024,
83
+ imagePixels: 67_108_864,
84
+ });
85
+
86
+ /** XLSX codec around the canonical Workbook model. */
87
+ // eslint-disable-next-line typescript/no-extraneous-class -- Skill-compatible static facade.
88
+ export class SpreadsheetXlsxCodec {
89
+ static async importXlsx(
90
+ input: FileBlob | Blob | ArrayBuffer | Uint8Array,
91
+ options: SpreadsheetXlsxImportOptions = {},
92
+ ): Promise<Workbook> {
93
+ validateImportOptions(options);
94
+ const limits = resolveImportLimits(options.limits);
95
+ const sourceBytes = await toBytes(input, limits.compressedBytes);
96
+ const preflight = await preflightXlsx(sourceBytes, limits);
97
+ if (hasOpaqueContent(preflight.opaqueContent) && options.unsupportedContent === "error") {
98
+ throw new SpreadsheetFidelityError(
99
+ "The XLSX contains bounded inert OOXML outside the editable spreadsheet model",
100
+ opaqueContentIssues(preflight.opaqueContent, "error"),
101
+ );
102
+ }
103
+
104
+ const excelWorkbook = await createExcelWorkbook();
105
+ // toBytes always returns an owned, full-span Uint8Array. Hand that buffer to ExcelJS directly;
106
+ // retaining unsupported OOXML then needs only this one canonical package copy.
107
+ await excelWorkbook.xlsx.load(
108
+ sourceBytes.buffer as Parameters<typeof excelWorkbook.xlsx.load>[0],
109
+ );
110
+ const workbook = importWorkbook(excelWorkbook, preflight.comments);
111
+ LOSS_PRESERVATION.set(workbook, {
112
+ sourceBytes,
113
+ sourceDigest: await sha256Hex(sourceBytes),
114
+ importedRevision: workbook.revision,
115
+ opaqueContent: cloneOpaqueContent(preflight.opaqueContent),
116
+ modelDigest: await workbookModelDigest(workbook),
117
+ });
118
+ return workbook;
119
+ }
120
+
121
+ static async exportXlsx(
122
+ workbook: Workbook,
123
+ options: SpreadsheetXlsxExportOptions = {},
124
+ ): Promise<FileBlob> {
125
+ validateExportOptions(options);
126
+ // Validation also catches hostile state injected through reflection before any source bytes
127
+ // are returned or ExcelJS is allocated.
128
+ workbook.toJSON();
129
+ const envelope = LOSS_PRESERVATION.get(workbook);
130
+ if (envelope && (await workbookModelDigest(workbook)) === envelope.modelDigest) {
131
+ return FileBlob.fromBytes(envelope.sourceBytes.slice(), {
132
+ type: XLSX_MEDIA_TYPE,
133
+ name: options.fileName ?? "workbook.xlsx",
134
+ });
135
+ }
136
+
137
+ const issues = this.fidelityReport(workbook).filter((issue) => issue.severity === "error");
138
+ if (issues.length > 0 && options.unsupportedContent !== "discard") {
139
+ throw new SpreadsheetFidelityError(
140
+ 'XLSX export would discard unsupported editable content; remove it or explicitly export with unsupportedContent: "discard"',
141
+ issues,
142
+ );
143
+ }
144
+
145
+ const excelWorkbook = await exportWorkbook(workbook);
146
+ const output = await excelWorkbook.xlsx.writeBuffer({
147
+ zip: { compression: "DEFLATE", compressionOptions: { level: 6 } },
148
+ });
149
+ return FileBlob.fromBytes(normalizeGeneratedZip(new Uint8Array(output)), {
150
+ type: XLSX_MEDIA_TYPE,
151
+ name: options.fileName ?? "workbook.xlsx",
152
+ });
153
+ }
154
+
155
+ static fidelityReport(workbook: Workbook): readonly SpreadsheetFidelityIssue[] {
156
+ workbook.toJSON();
157
+ return spreadsheetFidelityReport(workbook);
158
+ }
159
+
160
+ /**
161
+ * Returns a portable copy of the opaque original-package envelope.
162
+ * Persist it beside Workbook.toJSON(); the Workbook JSON intentionally does not contain Office bytes.
163
+ */
164
+ static lossPreservationEnvelope(workbook: Workbook): SpreadsheetLossPreservationEnvelope | null {
165
+ workbook.toJSON();
166
+ return spreadsheetLossPreservationEnvelope(workbook);
167
+ }
168
+
169
+ /** Reattaches a separately persisted envelope to a restored canonical workbook snapshot. */
170
+ static async attachLossPreservationEnvelope(
171
+ workbook: Workbook,
172
+ envelope: SpreadsheetLossPreservationEnvelope,
173
+ options: Pick<SpreadsheetXlsxImportOptions, "limits"> = {},
174
+ ): Promise<void> {
175
+ workbook.toJSON();
176
+ validateAttachOptions(options);
177
+ const normalizedEnvelope = normalizeLossPreservationEnvelope(envelope);
178
+ const sourceBytes = normalizedEnvelope.sourceBytes;
179
+ if (normalizedEnvelope.sourceDigest !== (await sha256Hex(sourceBytes))) {
180
+ throw new SpreadsheetSecurityError(
181
+ "Spreadsheet loss-preservation source digest does not match its bytes",
182
+ "invalid-package",
183
+ );
184
+ }
185
+ const preflight = await preflightXlsx(sourceBytes, resolveImportLimits(options.limits));
186
+ if (!sameOpaqueContent(preflight.opaqueContent, normalizedEnvelope.opaqueContent)) {
187
+ throw new SpreadsheetSecurityError(
188
+ "Spreadsheet loss-preservation envelope metadata does not match its XLSX bytes",
189
+ "invalid-package",
190
+ );
191
+ }
192
+ const projectedSource = await createExcelWorkbook();
193
+ await projectedSource.xlsx.load(
194
+ sourceBytes.buffer as Parameters<typeof projectedSource.xlsx.load>[0],
195
+ );
196
+ const projectedDigest = await workbookModelDigest(
197
+ importWorkbook(projectedSource, preflight.comments),
198
+ );
199
+ const targetDigest = await workbookModelDigest(workbook);
200
+ if (
201
+ projectedDigest !== normalizedEnvelope.modelDigest ||
202
+ targetDigest !== normalizedEnvelope.modelDigest
203
+ ) {
204
+ throw new SpreadsheetSecurityError(
205
+ "Spreadsheet loss-preservation envelope belongs to a different workbook snapshot",
206
+ "invalid-package",
207
+ );
208
+ }
209
+ LOSS_PRESERVATION.set(workbook, {
210
+ sourceBytes,
211
+ sourceDigest: normalizedEnvelope.sourceDigest,
212
+ importedRevision: workbook.revision,
213
+ opaqueContent: cloneOpaqueContent(preflight.opaqueContent),
214
+ modelDigest: normalizedEnvelope.modelDigest,
215
+ });
216
+ }
217
+ }
218
+
219
+ function normalizeLossPreservationEnvelope(input: unknown): {
220
+ sourceBytes: Uint8Array;
221
+ sourceDigest: string;
222
+ opaqueContent: SpreadsheetOpaqueContent;
223
+ modelDigest: string;
224
+ } {
225
+ if (
226
+ input === null ||
227
+ typeof input !== "object" ||
228
+ Array.isArray(input) ||
229
+ Object.getPrototypeOf(input) !== Object.prototype
230
+ ) {
231
+ throw new TypeError("Spreadsheet loss-preservation envelope must be a plain object");
232
+ }
233
+ const record = input as Record<string, unknown>;
234
+ const required = new Set([
235
+ "version",
236
+ "mediaType",
237
+ "sourceBytes",
238
+ "sourceDigest",
239
+ "opaqueContent",
240
+ "modelDigest",
241
+ ]);
242
+ const allowed = required;
243
+ for (const key of Reflect.ownKeys(record)) {
244
+ if (typeof key !== "string" || !allowed.has(key)) {
245
+ throw new TypeError("Spreadsheet loss-preservation envelope contains an unknown property");
246
+ }
247
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
248
+ if (!descriptor || !("value" in descriptor)) {
249
+ throw new TypeError(`Spreadsheet loss-preservation envelope ${key} must be plain data`);
250
+ }
251
+ }
252
+ for (const key of required) {
253
+ if (!Object.hasOwn(record, key)) {
254
+ throw new TypeError(`Spreadsheet loss-preservation envelope ${key} is required`);
255
+ }
256
+ }
257
+ if (record.version !== 1 || record.mediaType !== XLSX_MEDIA_TYPE) {
258
+ throw new TypeError("Unsupported spreadsheet loss-preservation envelope");
259
+ }
260
+ const source = record.sourceBytes;
261
+ if (
262
+ !(source instanceof Uint8Array) ||
263
+ Object.getPrototypeOf(source) !== Uint8Array.prototype ||
264
+ source.byteLength > DEFAULT_XLSX_IMPORT_LIMITS.compressedBytes
265
+ ) {
266
+ throw new TypeError("Spreadsheet loss-preservation sourceBytes must be a bounded Uint8Array");
267
+ }
268
+ if (typeof record.sourceDigest !== "string" || !/^[0-9a-f]{64}$/.test(record.sourceDigest)) {
269
+ throw new TypeError("Spreadsheet loss-preservation sourceDigest must be canonical SHA-256");
270
+ }
271
+ const opaqueContent = normalizeOpaqueContent(record.opaqueContent);
272
+ if (typeof record.modelDigest !== "string" || !/^[0-9a-f]{64}$/.test(record.modelDigest)) {
273
+ throw new TypeError("Spreadsheet loss-preservation modelDigest must be canonical SHA-256");
274
+ }
275
+ return {
276
+ sourceBytes: Uint8Array.prototype.slice.call(source) as Uint8Array,
277
+ sourceDigest: record.sourceDigest,
278
+ opaqueContent,
279
+ modelDigest: record.modelDigest,
280
+ };
281
+ }
282
+
283
+ function normalizeOpaqueContent(input: unknown): SpreadsheetOpaqueContent {
284
+ assertPlainDataRecord(
285
+ input,
286
+ new Set(["parts", "relationships", "contentTypes", "features"]),
287
+ "Spreadsheet opaque-content metadata",
288
+ );
289
+ const parts = denseDataArray(
290
+ input.parts,
291
+ DEFAULT_XLSX_IMPORT_LIMITS.entries,
292
+ "Spreadsheet opaque part list",
293
+ ).map((part) => {
294
+ if (typeof part !== "string" || !isCanonicalPartName(part)) {
295
+ throw new TypeError("Spreadsheet opaque part name is invalid");
296
+ }
297
+ return part;
298
+ });
299
+ const relationships = denseDataArray(
300
+ input.relationships,
301
+ DEFAULT_XLSX_IMPORT_LIMITS.relationships,
302
+ "Spreadsheet opaque relationship list",
303
+ ).map((relationship) => {
304
+ assertPlainDataRecord(
305
+ relationship,
306
+ new Set(["sourcePart", "type", "targetPart"]),
307
+ "Spreadsheet opaque relationship",
308
+ );
309
+ const { sourcePart, type, targetPart } = relationship;
310
+ if (
311
+ typeof sourcePart !== "string" ||
312
+ (sourcePart !== "" && !isCanonicalPartName(sourcePart)) ||
313
+ typeof type !== "string" ||
314
+ type.length === 0 ||
315
+ type.length > 2_048 ||
316
+ !/^[\x21-\x7e]+$/.test(type) ||
317
+ typeof targetPart !== "string" ||
318
+ !isCanonicalPartName(targetPart)
319
+ ) {
320
+ throw new TypeError("Spreadsheet opaque relationship metadata is invalid");
321
+ }
322
+ return { sourcePart, type, targetPart };
323
+ });
324
+ const contentTypes = denseDataArray(
325
+ input.contentTypes,
326
+ DEFAULT_XLSX_IMPORT_LIMITS.entries,
327
+ "Spreadsheet opaque content-type list",
328
+ ).map((contentType) => {
329
+ assertPlainDataRecord(
330
+ contentType,
331
+ new Set(["partName", "contentType"]),
332
+ "Spreadsheet opaque content type",
333
+ );
334
+ if (
335
+ typeof contentType.partName !== "string" ||
336
+ !isCanonicalPartName(contentType.partName) ||
337
+ typeof contentType.contentType !== "string" ||
338
+ contentType.contentType.length === 0 ||
339
+ contentType.contentType.length > 2_048 ||
340
+ !/^[\x21-\x7e]+$/.test(contentType.contentType)
341
+ ) {
342
+ throw new TypeError("Spreadsheet opaque content-type metadata is invalid");
343
+ }
344
+ return {
345
+ partName: contentType.partName,
346
+ contentType: contentType.contentType,
347
+ };
348
+ });
349
+ const features = denseDataArray(input.features, 4_096, "Spreadsheet opaque feature list").map(
350
+ (feature) => {
351
+ if (typeof feature !== "string" || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(feature)) {
352
+ throw new TypeError("Spreadsheet opaque feature name is invalid");
353
+ }
354
+ return feature;
355
+ },
356
+ );
357
+ return {
358
+ parts: canonicalUnique(parts, compareStrings, "Spreadsheet opaque part list"),
359
+ relationships: canonicalUnique(
360
+ relationships,
361
+ compareOpaqueRelationships,
362
+ "Spreadsheet opaque relationship list",
363
+ ),
364
+ contentTypes: canonicalUnique(
365
+ contentTypes,
366
+ compareOpaqueContentTypes,
367
+ "Spreadsheet opaque content-type list",
368
+ ),
369
+ features: canonicalUnique(features, compareStrings, "Spreadsheet opaque feature list"),
370
+ };
371
+ }
372
+
373
+ function canonicalUnique<T>(
374
+ values: readonly T[],
375
+ compare: (left: T, right: T) => number,
376
+ label: string,
377
+ ): T[] {
378
+ const sorted = [...values].sort(compare);
379
+ for (let index = 1; index < sorted.length; index += 1) {
380
+ if (compare(sorted[index - 1]!, sorted[index]!) === 0) {
381
+ throw new TypeError(`${label} contains duplicate metadata`);
382
+ }
383
+ }
384
+ return sorted;
385
+ }
386
+
387
+ function denseDataArray(value: unknown, maximum: number, label: string): unknown[] {
388
+ if (
389
+ !Array.isArray(value) ||
390
+ Object.getPrototypeOf(value) !== Array.prototype ||
391
+ value.length > maximum
392
+ ) {
393
+ throw new TypeError(`${label} must be a bounded ordinary array`);
394
+ }
395
+ const descriptors = Object.getOwnPropertyDescriptors(value);
396
+ for (let index = 0; index < value.length; index += 1) {
397
+ const descriptor = descriptors[String(index)];
398
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
399
+ throw new TypeError(`${label} must contain only dense data elements`);
400
+ }
401
+ }
402
+ for (const key of Object.keys(descriptors)) {
403
+ if (key !== "length" && !/^(?:0|[1-9][0-9]*)$/.test(key)) {
404
+ throw new TypeError(`${label} contains an unknown property`);
405
+ }
406
+ }
407
+ return Array.prototype.slice.call(value) as unknown[];
408
+ }
409
+
410
+ function assertPlainDataRecord(
411
+ value: unknown,
412
+ allowedKeys: ReadonlySet<string>,
413
+ label: string,
414
+ ): asserts value is Record<string, unknown> {
415
+ if (
416
+ value === null ||
417
+ typeof value !== "object" ||
418
+ Array.isArray(value) ||
419
+ Object.getPrototypeOf(value) !== Object.prototype
420
+ ) {
421
+ throw new TypeError(`${label} must be a plain data object`);
422
+ }
423
+ if (Object.getOwnPropertySymbols(value).length > 0) {
424
+ throw new TypeError(`${label} must not contain symbol properties`);
425
+ }
426
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
427
+ if (!("value" in descriptor) || !descriptor.enumerable) {
428
+ throw new TypeError(`${label} must contain only enumerable data properties`);
429
+ }
430
+ if (!allowedKeys.has(key)) throw new TypeError(`${label} contains unknown property ${key}`);
431
+ }
432
+ }
433
+
434
+ function sameOpaqueContent(
435
+ left: SpreadsheetOpaqueContent,
436
+ right: SpreadsheetOpaqueContent,
437
+ ): boolean {
438
+ return JSON.stringify(left) === JSON.stringify(right);
439
+ }
440
+
441
+ function validateImportOptions(options: SpreadsheetXlsxImportOptions): void {
442
+ assertPlainDataRecord(options, new Set(["unsupportedContent", "limits"]), "XLSX import options");
443
+ if (
444
+ options.unsupportedContent !== undefined &&
445
+ options.unsupportedContent !== "preserve" &&
446
+ options.unsupportedContent !== "error"
447
+ ) {
448
+ throw new TypeError("XLSX unsupportedContent import policy is invalid");
449
+ }
450
+ if (options.limits !== undefined) {
451
+ assertPlainDataRecord(
452
+ options.limits,
453
+ new Set(Object.keys(DEFAULT_XLSX_IMPORT_LIMITS)),
454
+ "XLSX import limits",
455
+ );
456
+ }
457
+ }
458
+
459
+ function validateExportOptions(options: SpreadsheetXlsxExportOptions): void {
460
+ assertPlainDataRecord(
461
+ options,
462
+ new Set(["fileName", "unsupportedContent"]),
463
+ "XLSX export options",
464
+ );
465
+ if (
466
+ options.unsupportedContent !== undefined &&
467
+ options.unsupportedContent !== "error" &&
468
+ options.unsupportedContent !== "discard"
469
+ ) {
470
+ throw new TypeError("XLSX unsupportedContent export policy is invalid");
471
+ }
472
+ if (
473
+ options.fileName !== undefined &&
474
+ (typeof options.fileName !== "string" ||
475
+ options.fileName.length === 0 ||
476
+ options.fileName.length > 255 ||
477
+ /[\u0000-\u001f\u007f]/.test(options.fileName))
478
+ ) {
479
+ throw new TypeError("XLSX export fileName must contain 1 through 255 safe characters");
480
+ }
481
+ }
482
+
483
+ function validateAttachOptions(options: Pick<SpreadsheetXlsxImportOptions, "limits">): void {
484
+ assertPlainDataRecord(options, new Set(["limits"]), "XLSX envelope attach options");
485
+ if (options.limits !== undefined) {
486
+ assertPlainDataRecord(
487
+ options.limits,
488
+ new Set(Object.keys(DEFAULT_XLSX_IMPORT_LIMITS)),
489
+ "XLSX import limits",
490
+ );
491
+ }
492
+ }
493
+
494
+ async function createExcelWorkbook(): Promise<ExcelJS.Workbook> {
495
+ const module = await import("exceljs");
496
+ const runtime = (module.default ?? module) as unknown as {
497
+ Workbook: new () => ExcelJS.Workbook;
498
+ };
499
+ return new runtime.Workbook();
500
+ }
501
+
502
+ type ImportedLegacyComment = {
503
+ sheetName: string;
504
+ cell: string;
505
+ text: string;
506
+ };
507
+
508
+ function importWorkbook(
509
+ source: ExcelJS.Workbook,
510
+ legacyComments: readonly ImportedLegacyComment[] = [],
511
+ ): Workbook {
512
+ const workbook = Workbook.create();
513
+ workbook.transact(() => {
514
+ for (const sourceSheet of source.worksheets) {
515
+ const worksheet = workbook.worksheets.add(sourceSheet.name);
516
+ const view = sourceSheet.views[0];
517
+ worksheet.showGridLines = view?.showGridLines ?? true;
518
+ if (view?.state === "frozen") {
519
+ if (view.ySplit) worksheet.freezePanes.freezeRows(view.ySplit);
520
+ if (view.xSplit) worksheet.freezePanes.freezeColumns(view.xSplit);
521
+ }
522
+
523
+ sourceSheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
524
+ if (row.height && row.height > 0)
525
+ worksheet.setRowHeight(rowNumber - 1, pointsToPixels(row.height));
526
+ row.eachCell({ includeEmpty: false }, (cell, columnNumber) => {
527
+ const value = importCellValue(cell.value);
528
+ const formula = formulaOf(cell.value);
529
+ worksheet.setCell(
530
+ rowNumber - 1,
531
+ columnNumber - 1,
532
+ {
533
+ value: formula ? null : value,
534
+ formula,
535
+ format: importCellFormat(cell),
536
+ },
537
+ "content",
538
+ );
539
+ importDataValidation(worksheet, rowNumber - 1, columnNumber - 1, cell.dataValidation);
540
+ });
541
+ });
542
+
543
+ for (let columnNumber = 1; columnNumber <= sourceSheet.columnCount; columnNumber += 1) {
544
+ const width = sourceSheet.getColumn(columnNumber).width;
545
+ if (width && width > 0)
546
+ worksheet.setColumnWidth(columnNumber - 1, excelWidthToPixels(width));
547
+ }
548
+ for (const merge of sourceSheet.model.merges ?? []) worksheet.mergeCells(merge);
549
+ importTables(worksheet, sourceSheet);
550
+ importImages(source, worksheet, sourceSheet);
551
+ importConditionalFormattings(worksheet, sourceSheet);
552
+ }
553
+ for (const comment of legacyComments) {
554
+ const worksheet = workbook.worksheets.getItem(comment.sheetName);
555
+ workbook.comments.addThread({ cell: worksheet.getRange(comment.cell) }, comment.text);
556
+ }
557
+ });
558
+ return workbook;
559
+ }
560
+
561
+ async function exportWorkbook(workbook: Workbook): Promise<ExcelJS.Workbook> {
562
+ const output = await createExcelWorkbook();
563
+ output.creator = "OpenGeni";
564
+ output.lastModifiedBy = "OpenGeni";
565
+ output.created = new Date(0);
566
+ output.modified = new Date(0);
567
+ output.calcProperties.fullCalcOnLoad = true;
568
+
569
+ for (const worksheet of workbook.worksheets.items) {
570
+ const sourceFreeze = worksheet.freezePanes.snapshot();
571
+ const views: Array<Partial<ExcelJS.WorksheetView>> =
572
+ sourceFreeze.rows || sourceFreeze.columns
573
+ ? [
574
+ {
575
+ state: "frozen",
576
+ xSplit: sourceFreeze.columns,
577
+ ySplit: sourceFreeze.rows,
578
+ showGridLines: worksheet.showGridLines,
579
+ },
580
+ ]
581
+ : [{ state: "normal", showGridLines: worksheet.showGridLines }];
582
+ const sheet = output.addWorksheet(worksheet.name, { views });
583
+
584
+ for (const { row, col, data } of worksheet.cellEntries()) {
585
+ const cell = sheet.getCell(row + 1, col + 1);
586
+ if (data.formula) {
587
+ assertSafeFormula(data.formula, `${worksheet.name}!${formatCellAddress({ row, col })}`);
588
+ const result = workbook.valueAt(worksheet, { row, col });
589
+ cell.value = {
590
+ formula: data.formula.startsWith("=") ? data.formula.slice(1) : data.formula,
591
+ ...(formulaResultForExcel(result) === undefined
592
+ ? {}
593
+ : { result: formulaResultForExcel(result) }),
594
+ } as ExcelJS.CellFormulaValue;
595
+ } else {
596
+ cell.value = exportCellValue(data.value);
597
+ }
598
+ applyCellFormat(cell, data.format);
599
+ }
600
+
601
+ const used = worksheet.usedRangeAddress();
602
+ if (used) {
603
+ for (let col = used.col; col < used.col + used.colCount; col += 1) {
604
+ sheet.getColumn(col + 1).width = pixelsToExcelWidth(worksheet.columnWidth(col));
605
+ }
606
+ for (let row = used.row; row < used.row + used.rowCount; row += 1) {
607
+ sheet.getRow(row + 1).height = pixelsToPoints(worksheet.rowHeight(row));
608
+ }
609
+ }
610
+ for (const merge of worksheet.mergeRegions()) sheet.mergeCells(formatRangeAddress(merge));
611
+ exportDataValidations(worksheet, sheet);
612
+ exportConditionalFormattings(worksheet, sheet);
613
+ exportTables(worksheet, sheet);
614
+ exportImages(output, worksheet, sheet);
615
+ exportComments(workbook, worksheet, sheet);
616
+ }
617
+ return output;
618
+ }
619
+
620
+ function importCellValue(value: ExcelJS.CellValue): CellValue {
621
+ if (value === undefined || value === null) return null;
622
+ if (
623
+ typeof value === "string" ||
624
+ typeof value === "number" ||
625
+ typeof value === "boolean" ||
626
+ value instanceof Date
627
+ )
628
+ return value;
629
+ if ("formula" in value || "sharedFormula" in value) {
630
+ const result = value.result;
631
+ if (result === undefined) return null;
632
+ if (
633
+ typeof result === "string" ||
634
+ typeof result === "number" ||
635
+ typeof result === "boolean" ||
636
+ result instanceof Date
637
+ )
638
+ return result;
639
+ if ("error" in result) return result.error;
640
+ return null;
641
+ }
642
+ if ("error" in value) return value.error;
643
+ if ("richText" in value) return value.richText.map((part) => part.text).join("");
644
+ if ("text" in value) return value.text;
645
+ return null;
646
+ }
647
+
648
+ function formulaOf(value: ExcelJS.CellValue): string | null {
649
+ if (!value || typeof value !== "object") return null;
650
+ if ("formula" in value && value.formula) return normalizeFormula(value.formula);
651
+ if ("sharedFormula" in value && value.formula) return normalizeFormula(value.formula);
652
+ return null;
653
+ }
654
+
655
+ function importCellFormat(cell: ExcelJS.Cell): CellFormat {
656
+ const fill = cell.fill?.type === "pattern" ? colorFromExcel(cell.fill.fgColor) : undefined;
657
+ const fontColor = colorFromExcel(cell.font?.color);
658
+ const border = importBorders(cell.border);
659
+ const format: CellFormat = {};
660
+ if (fill) format.fill = fill;
661
+ const font: NonNullable<CellFormat["font"]> = {};
662
+ if (cell.font?.name) font.name = cell.font.name;
663
+ if (cell.font?.size !== undefined) font.size = cell.font.size;
664
+ if (cell.font?.bold !== undefined) font.bold = cell.font.bold;
665
+ if (cell.font?.italic !== undefined) font.italic = cell.font.italic;
666
+ if (cell.font?.underline !== undefined)
667
+ font.underline = Boolean(cell.font.underline && cell.font.underline !== "none");
668
+ if (fontColor) font.color = fontColor;
669
+ if (Object.keys(font).length > 0) format.font = font;
670
+ if (cell.numFmt) format.numberFormat = cell.numFmt;
671
+ if (border) format.borders = border;
672
+ const horizontal = importHorizontalAlignment(cell.alignment?.horizontal);
673
+ if (horizontal) format.horizontalAlignment = horizontal;
674
+ const vertical = importVerticalAlignment(cell.alignment?.vertical);
675
+ if (vertical) format.verticalAlignment = vertical;
676
+ if (cell.alignment?.wrapText !== undefined) format.wrapText = cell.alignment.wrapText;
677
+ return format;
678
+ }
679
+
680
+ function applyCellFormat(cell: ExcelJS.Cell, format: CellFormat): void {
681
+ if (format.fill) {
682
+ cell.fill = {
683
+ type: "pattern",
684
+ pattern: "solid",
685
+ fgColor: { argb: colorToArgb(format.fill) },
686
+ };
687
+ }
688
+ if (format.font) {
689
+ const font: Partial<ExcelJS.Font> = {};
690
+ if (format.font.name !== undefined) font.name = format.font.name;
691
+ if (format.font.size !== undefined) font.size = format.font.size;
692
+ if (format.font.bold !== undefined) font.bold = format.font.bold;
693
+ if (format.font.italic !== undefined) font.italic = format.font.italic;
694
+ if (format.font.underline !== undefined) font.underline = format.font.underline;
695
+ if (format.font.color) font.color = { argb: colorToArgb(format.font.color) };
696
+ cell.font = font;
697
+ }
698
+ if (format.numberFormat) cell.numFmt = format.numberFormat;
699
+ if (format.borders) cell.border = exportBorders(format.borders);
700
+ const alignment: Partial<ExcelJS.Alignment> = {};
701
+ if (format.horizontalAlignment) alignment.horizontal = format.horizontalAlignment;
702
+ if (format.verticalAlignment)
703
+ alignment.vertical =
704
+ format.verticalAlignment === "center" ? "middle" : format.verticalAlignment;
705
+ if (format.wrapText !== undefined) alignment.wrapText = format.wrapText;
706
+ if (Object.keys(alignment).length > 0) cell.alignment = alignment;
707
+ }
708
+
709
+ function importBorders(borders: Partial<ExcelJS.Borders> | undefined): CellFormat["borders"] {
710
+ if (!borders) return undefined;
711
+ const imported: NonNullable<CellFormat["borders"]> = {};
712
+ const top = importBorder(borders.top);
713
+ const right = importBorder(borders.right);
714
+ const bottom = importBorder(borders.bottom);
715
+ const left = importBorder(borders.left);
716
+ if (top) imported.top = top;
717
+ if (right) imported.right = right;
718
+ if (bottom) imported.bottom = bottom;
719
+ if (left) imported.left = left;
720
+ return Object.keys(imported).length > 0 ? imported : undefined;
721
+ }
722
+
723
+ function importBorder(border: Partial<ExcelJS.Border> | undefined): BorderConfig | undefined {
724
+ if (!border?.style) return undefined;
725
+ const imported: BorderConfig = { style: border.style };
726
+ const color = colorFromExcel(border.color);
727
+ if (color) imported.color = color;
728
+ return imported;
729
+ }
730
+
731
+ function exportBorders(borders: NonNullable<CellFormat["borders"]>): Partial<ExcelJS.Borders> {
732
+ const fallback: BorderConfig | undefined =
733
+ borders.style || borders.color
734
+ ? {
735
+ style: borders.style ?? "thin",
736
+ ...(borders.color ? { color: borders.color } : {}),
737
+ }
738
+ : undefined;
739
+ const useFallback = borders.preset === "all" || borders.preset === "outside";
740
+ const exported: Partial<ExcelJS.Borders> = {};
741
+ const top = exportBorder(borders.top ?? (useFallback ? fallback : undefined));
742
+ const right = exportBorder(borders.right ?? (useFallback ? fallback : undefined));
743
+ const doubleBottom: BorderConfig = {
744
+ style: "double",
745
+ ...(borders.color ? { color: borders.color } : {}),
746
+ };
747
+ const bottom = exportBorder(
748
+ borders.bottom ??
749
+ (borders.preset === "doubleBottom" ? doubleBottom : useFallback ? fallback : undefined),
750
+ );
751
+ const left = exportBorder(borders.left ?? (useFallback ? fallback : undefined));
752
+ if (top) exported.top = top;
753
+ if (right) exported.right = right;
754
+ if (bottom) exported.bottom = bottom;
755
+ if (left) exported.left = left;
756
+ return exported;
757
+ }
758
+
759
+ function exportBorder(border: BorderConfig | undefined): Partial<ExcelJS.Border> | undefined {
760
+ if (!border) return undefined;
761
+ const exported: Partial<ExcelJS.Border> = {
762
+ style: excelBorderStyle(border.style),
763
+ };
764
+ if (border.color) exported.color = { argb: colorToArgb(border.color) };
765
+ return exported;
766
+ }
767
+
768
+ function importDataValidation(
769
+ worksheet: Worksheet,
770
+ row: number,
771
+ col: number,
772
+ validation: ExcelJS.DataValidation,
773
+ ): void {
774
+ if (!validation?.type) return;
775
+ worksheet.dataValidations.add({
776
+ range: formatCellAddress({ row, col }),
777
+ rule: importDataValidationRule(validation),
778
+ });
779
+ }
780
+
781
+ function exportDataValidations(worksheet: Worksheet, target: ExcelJS.Worksheet): void {
782
+ for (const entry of worksheet.dataValidations.all()) {
783
+ const validation = exportDataValidationRule(entry.config.rule);
784
+ forEachCell(entry.range, (row, col) => {
785
+ target.getCell(row + 1, col + 1).dataValidation = structuredClone(validation);
786
+ });
787
+ }
788
+ }
789
+
790
+ function importDataValidationRule(validation: ExcelJS.DataValidation): Record<string, unknown> {
791
+ const source = structuredClone(validation as unknown as Record<string, unknown>);
792
+ const formulae = source.formulae;
793
+ if (source.type === "list" && Array.isArray(formulae) && formulae.length === 1) {
794
+ const values = inlineValidationValues(formulae[0]);
795
+ if (values) {
796
+ delete source.formulae;
797
+ source.values = values;
798
+ }
799
+ }
800
+ return source;
801
+ }
802
+
803
+ function exportDataValidationRule(rule: Record<string, unknown>): ExcelJS.DataValidation {
804
+ const output = structuredClone(rule);
805
+ if (Array.isArray(output.values)) {
806
+ if (output.type !== "list" || output.values.some((value) => typeof value !== "string")) {
807
+ throw new TypeError("Data validation values require a list of strings");
808
+ }
809
+ const literal = `"${output.values
810
+ .map((value) => (value as string).replaceAll('"', '""'))
811
+ .join(",")}"`;
812
+ if (literal.length > 255) {
813
+ throw new TypeError("Inline data validation values exceed Excel's 255-character limit");
814
+ }
815
+ output.formulae = [literal];
816
+ delete output.values;
817
+ } else if (!Array.isArray(output.formulae)) {
818
+ const formulae = [];
819
+ if (output.formula1 !== undefined) formulae.push(output.formula1);
820
+ if (output.formula2 !== undefined) formulae.push(output.formula2);
821
+ if (formulae.length > 0) output.formulae = formulae;
822
+ delete output.formula1;
823
+ delete output.formula2;
824
+ }
825
+ return output as unknown as ExcelJS.DataValidation;
826
+ }
827
+
828
+ function inlineValidationValues(value: unknown): string[] | null {
829
+ if (
830
+ typeof value !== "string" ||
831
+ value.length < 2 ||
832
+ !value.startsWith('"') ||
833
+ !value.endsWith('"')
834
+ ) {
835
+ return null;
836
+ }
837
+ const input = value.slice(1, -1);
838
+ const values: string[] = [];
839
+ let current = "";
840
+ for (let index = 0; index < input.length; index += 1) {
841
+ const character = input[index]!;
842
+ if (character === '"' && input[index + 1] === '"') {
843
+ current += '"';
844
+ index += 1;
845
+ } else if (character === ",") {
846
+ values.push(current);
847
+ current = "";
848
+ } else current += character;
849
+ }
850
+ values.push(current);
851
+ return values;
852
+ }
853
+
854
+ type ExcelConditionalFormatting = {
855
+ ref: string;
856
+ rules: Array<Record<string, unknown>>;
857
+ };
858
+
859
+ function importConditionalFormattings(worksheet: Worksheet, source: ExcelJS.Worksheet): void {
860
+ const entries =
861
+ (
862
+ source as unknown as {
863
+ conditionalFormattings?: ExcelConditionalFormatting[];
864
+ }
865
+ ).conditionalFormattings ?? [];
866
+ for (const entry of entries) {
867
+ const range = parseRangeAddress(entry.ref);
868
+ for (const sourceRule of entry.rules ?? []) {
869
+ const rule = stripUndefinedProperties(structuredClone(sourceRule));
870
+ const type = rule.type;
871
+ if (typeof type !== "string") throw new TypeError("XLSX conditional format type is missing");
872
+ // Strict preflight records safe, unsupported rule kinds as source-only fidelity. Do not
873
+ // coerce them into a different editable rule.
874
+ if (!SUPPORTED_CONDITIONAL_FORMAT_RULE_TYPES.has(type.toLowerCase())) continue;
875
+ delete rule.type;
876
+ delete rule.priority;
877
+ const formulae = rule.formulae;
878
+ if (Array.isArray(formulae)) {
879
+ const normalizedFormulae = formulae.map(importConditionalFormula);
880
+ rule.formula = normalizedFormulae.length === 1 ? normalizedFormulae[0] : normalizedFormulae;
881
+ delete rule.formulae;
882
+ }
883
+ if (rule.style && typeof rule.style === "object") {
884
+ rule.format = importConditionalStyle(rule.style as Record<string, unknown>);
885
+ delete rule.style;
886
+ }
887
+ worksheet.conditionalFormattings.add(range, type, rule);
888
+ }
889
+ }
890
+ }
891
+
892
+ function stripUndefinedProperties<T>(value: T): T {
893
+ if (Array.isArray(value)) {
894
+ return value.map((item) => stripUndefinedProperties(item)) as T;
895
+ }
896
+ if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
897
+ const output: Record<string, unknown> = {};
898
+ for (const [key, item] of Object.entries(value)) {
899
+ if (item !== undefined) output[key] = stripUndefinedProperties(item);
900
+ }
901
+ return output as T;
902
+ }
903
+ return value;
904
+ }
905
+
906
+ function exportConditionalFormattings(worksheet: Worksheet, target: ExcelJS.Worksheet): void {
907
+ const add = (
908
+ target as unknown as {
909
+ addConditionalFormatting: (input: ExcelConditionalFormatting) => void;
910
+ }
911
+ ).addConditionalFormatting.bind(target);
912
+ for (const entry of worksheet.conditionalFormattings.all()) {
913
+ const config = structuredClone(entry.config);
914
+ const formula = config.formula;
915
+ if (formula !== undefined) {
916
+ config.formulae = Array.isArray(formula) ? formula : [formula];
917
+ delete config.formula;
918
+ }
919
+ if (config.format && typeof config.format === "object") {
920
+ config.style = exportConditionalStyle(config.format as Record<string, unknown>);
921
+ delete config.format;
922
+ }
923
+ add({
924
+ ref: formatRangeAddress(entry.range),
925
+ rules: [{ type: entry.ruleType, ...config }],
926
+ });
927
+ }
928
+ }
929
+
930
+ function importConditionalStyle(style: Record<string, unknown>): Record<string, unknown> {
931
+ const output: Record<string, unknown> = {};
932
+ const fill = style.fill as
933
+ | { fgColor?: Partial<ExcelJS.Color>; bgColor?: Partial<ExcelJS.Color> }
934
+ | undefined;
935
+ const fillColor = colorFromExcel(fill?.fgColor ?? fill?.bgColor);
936
+ if (fillColor) output.fill = fillColor;
937
+ const font = style.font as
938
+ | (Partial<ExcelJS.Font> & { color?: Partial<ExcelJS.Color> })
939
+ | undefined;
940
+ const fontColor = colorFromExcel(font?.color);
941
+ if (font) {
942
+ const normalizedFont: Record<string, unknown> = {};
943
+ for (const key of ["name", "size", "bold", "italic", "underline"] as const) {
944
+ if (font[key] !== null && font[key] !== undefined) normalizedFont[key] = font[key];
945
+ }
946
+ if (fontColor) normalizedFont.color = fontColor;
947
+ if (Object.keys(normalizedFont).length > 0) output.font = normalizedFont;
948
+ }
949
+ if (style.border && typeof style.border === "object") output.border = style.border;
950
+ if (typeof style.numFmt === "string") output.numberFormat = style.numFmt;
951
+ return output;
952
+ }
953
+
954
+ function importConditionalFormula(value: unknown): unknown {
955
+ if (typeof value === "string" && /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[Ee][+-]?\d+)?$/.test(value)) {
956
+ const number = Number(value);
957
+ if (Number.isFinite(number)) return number;
958
+ }
959
+ return value;
960
+ }
961
+
962
+ function exportConditionalStyle(format: Record<string, unknown>): Record<string, unknown> {
963
+ const output = structuredClone(format);
964
+ if (typeof output.fill === "string") {
965
+ output.fill = {
966
+ type: "pattern",
967
+ pattern: "solid",
968
+ fgColor: { argb: colorToArgb(output.fill) },
969
+ };
970
+ }
971
+ const font = output.font as { color?: unknown } | undefined;
972
+ if (font && typeof font.color === "string") {
973
+ output.font = { ...font, color: { argb: colorToArgb(font.color) } };
974
+ }
975
+ if (output.numberFormat !== undefined && output.numFmt === undefined) {
976
+ output.numFmt = output.numberFormat;
977
+ delete output.numberFormat;
978
+ }
979
+ return output;
980
+ }
981
+
982
+ function exportComments(workbook: Workbook, worksheet: Worksheet, target: ExcelJS.Worksheet): void {
983
+ for (const comment of workbook.toJSON().comments) {
984
+ if (comment.sheetId !== worksheet.id || comment.comments.length === 0) continue;
985
+ target.getCell(comment.cell.row + 1, comment.cell.col + 1).note = comment.comments
986
+ .map((item) => item.text)
987
+ .join("\n\n");
988
+ }
989
+ }
990
+
991
+ type ExcelTableModel = {
992
+ table: {
993
+ name: string;
994
+ tableRef: string;
995
+ headerRow: boolean;
996
+ totalsRow: boolean;
997
+ style?: {
998
+ theme?: string;
999
+ showColumnStripes?: boolean;
1000
+ };
1001
+ };
1002
+ };
1003
+
1004
+ function importTables(worksheet: Worksheet, source: ExcelJS.Worksheet): void {
1005
+ const tables = source.getTables() as unknown as ExcelTableModel[];
1006
+ for (const sourceTable of tables) {
1007
+ const model = sourceTable.table;
1008
+ if (!model?.name || !model.tableRef) continue;
1009
+ const table = worksheet.tables.add(model.tableRef, model.headerRow !== false, model.name);
1010
+ table.showTotals = model.totalsRow === true;
1011
+ table.style = model.style?.theme ?? "TableStyleMedium2";
1012
+ table.showBandedColumns = model.style?.showColumnStripes ?? false;
1013
+ }
1014
+ }
1015
+
1016
+ function exportTables(worksheet: Worksheet, target: ExcelJS.Worksheet): void {
1017
+ for (const table of worksheet.tables.items) {
1018
+ const values = table.range.values;
1019
+ const firstRow = values[0] ?? [];
1020
+ const columns = firstRow.map((value, index) => ({
1021
+ name: String(value ?? `Column${index + 1}`),
1022
+ filterButton: table.showFilterButton,
1023
+ }));
1024
+ if (columns.length === 0) continue;
1025
+ target.addTable({
1026
+ name: table.name,
1027
+ ref: formatCellAddress(table.range.address),
1028
+ headerRow: table.hasHeaders && table.showHeaders,
1029
+ totalsRow: table.showTotals,
1030
+ style: {
1031
+ theme: excelTableTheme(table.style),
1032
+ showColumnStripes: table.showBandedColumns,
1033
+ showRowStripes: !table.showBandedColumns,
1034
+ },
1035
+ columns,
1036
+ rows: exportTableRows(
1037
+ worksheet,
1038
+ table.range.address,
1039
+ table.hasHeaders ? 1 : 0,
1040
+ table.showTotals ? 1 : 0,
1041
+ ),
1042
+ });
1043
+ }
1044
+ }
1045
+
1046
+ function exportTableRows(
1047
+ worksheet: Worksheet,
1048
+ address: RangeAddress,
1049
+ headerRows: number,
1050
+ totalRows: number,
1051
+ ): ExcelJS.CellValue[][] {
1052
+ const rows: ExcelJS.CellValue[][] = [];
1053
+ const end = address.row + address.rowCount - totalRows;
1054
+ for (let row = address.row + headerRows; row < end; row += 1) {
1055
+ const values: ExcelJS.CellValue[] = [];
1056
+ for (let col = address.col; col < address.col + address.colCount; col += 1) {
1057
+ const data = worksheet.cellData(row, col);
1058
+ if (data.formula) {
1059
+ assertSafeFormula(data.formula, `${worksheet.name}!${formatCellAddress({ row, col })}`);
1060
+ const result = formulaResultForExcel(worksheet.workbook.valueAt(worksheet, { row, col }));
1061
+ values.push({
1062
+ formula: data.formula.startsWith("=") ? data.formula.slice(1) : data.formula,
1063
+ ...(result === undefined ? {} : { result }),
1064
+ });
1065
+ } else {
1066
+ values.push(exportCellValue(data.value));
1067
+ }
1068
+ }
1069
+ rows.push(values);
1070
+ }
1071
+ return rows;
1072
+ }
1073
+
1074
+ function importImages(
1075
+ source: ExcelJS.Workbook,
1076
+ worksheet: Worksheet,
1077
+ sourceSheet: ExcelJS.Worksheet,
1078
+ ): void {
1079
+ for (const image of sourceSheet.getImages()) {
1080
+ const media = source.getImage(Number(image.imageId));
1081
+ if (!media?.buffer || !["png", "jpeg", "gif"].includes(media.extension)) continue;
1082
+ const range = image.range as unknown as {
1083
+ tl: { row: number; col: number };
1084
+ br?: { row: number; col: number };
1085
+ ext?: { width: number; height: number };
1086
+ };
1087
+ const fromRow = Math.max(0, Math.floor(range.tl.row));
1088
+ const fromCol = Math.max(0, Math.floor(range.tl.col));
1089
+ const rowOffsetPx = Math.max(0, (range.tl.row - fromRow) * worksheet.rowHeight(fromRow));
1090
+ const colOffsetPx = Math.max(0, (range.tl.col - fromCol) * worksheet.columnWidth(fromCol));
1091
+ const width = Math.max(
1092
+ 1,
1093
+ range.ext?.width ??
1094
+ ((range.br?.col ?? range.tl.col + 1) - range.tl.col) * worksheet.columnWidth(fromCol),
1095
+ );
1096
+ const height = Math.max(
1097
+ 1,
1098
+ range.ext?.height ??
1099
+ ((range.br?.row ?? range.tl.row + 1) - range.tl.row) * worksheet.rowHeight(fromRow),
1100
+ );
1101
+ worksheet.images.add({
1102
+ blob: copyArrayBuffer(media.buffer),
1103
+ contentType: media.extension === "jpeg" ? "image/jpeg" : `image/${media.extension}`,
1104
+ anchor: {
1105
+ from: {
1106
+ row: fromRow,
1107
+ col: fromCol,
1108
+ ...(rowOffsetPx > 0 ? { rowOffsetPx } : {}),
1109
+ ...(colOffsetPx > 0 ? { colOffsetPx } : {}),
1110
+ },
1111
+ extent: { widthPx: width, heightPx: height },
1112
+ },
1113
+ });
1114
+ }
1115
+ }
1116
+
1117
+ function exportImages(
1118
+ source: ExcelJS.Workbook,
1119
+ worksheet: Worksheet,
1120
+ target: ExcelJS.Worksheet,
1121
+ ): void {
1122
+ for (const image of worksheet.images.items) {
1123
+ const data = safeExportImage(image.config);
1124
+ const extension = imageExtension(data);
1125
+ const imageId = data.dataUrl
1126
+ ? source.addImage({ base64: data.dataUrl, extension })
1127
+ : data.blob
1128
+ ? source.addImage({
1129
+ buffer: data.blob as NonNullable<ExcelJS.Image["buffer"]>,
1130
+ extension,
1131
+ })
1132
+ : null;
1133
+ if (imageId === null) continue;
1134
+ const rowOffset =
1135
+ (data.anchor.from.rowOffsetPx ?? 0) / worksheet.rowHeight(data.anchor.from.row);
1136
+ const colOffset =
1137
+ (data.anchor.from.colOffsetPx ?? 0) / worksheet.columnWidth(data.anchor.from.col);
1138
+ target.addImage(imageId, {
1139
+ tl: {
1140
+ col: data.anchor.from.col + colOffset,
1141
+ row: data.anchor.from.row + rowOffset,
1142
+ },
1143
+ ext: {
1144
+ width: data.anchor.extent.widthPx,
1145
+ height: data.anchor.extent.heightPx,
1146
+ },
1147
+ editAs: "oneCell",
1148
+ });
1149
+ }
1150
+ }
1151
+
1152
+ function imageExtension(image: SpreadsheetImageConfig): "png" | "jpeg" | "gif" {
1153
+ const contentType = image.contentType ?? image.dataUrl?.slice(5, image.dataUrl.indexOf(";"));
1154
+ if (contentType === "image/jpeg" || contentType === "image/jpg") return "jpeg";
1155
+ if (contentType === "image/gif") return "gif";
1156
+ return "png";
1157
+ }
1158
+
1159
+ function formulaResultForExcel(
1160
+ value: FormulaResult,
1161
+ ): number | string | boolean | Date | ExcelJS.CellErrorValue | undefined {
1162
+ if (value === null) return undefined;
1163
+ if (typeof value === "string" && isFormulaError(value)) {
1164
+ if (value === "#CYCLE!") return { error: "#VALUE!" };
1165
+ return { error: value };
1166
+ }
1167
+ return value;
1168
+ }
1169
+
1170
+ function exportCellValue(value: CellValue): ExcelJS.CellValue {
1171
+ return value;
1172
+ }
1173
+
1174
+ function normalizeFormula(formula: string): string {
1175
+ return formula.startsWith("=") ? formula : `=${formula}`;
1176
+ }
1177
+
1178
+ function importHorizontalAlignment(
1179
+ value: ExcelJS.Alignment["horizontal"] | undefined,
1180
+ ): CellFormat["horizontalAlignment"] {
1181
+ if (value === "left" || value === "center" || value === "right" || value === "justify")
1182
+ return value;
1183
+ return undefined;
1184
+ }
1185
+
1186
+ function importVerticalAlignment(
1187
+ value: ExcelJS.Alignment["vertical"] | undefined,
1188
+ ): CellFormat["verticalAlignment"] {
1189
+ if (value === "middle") return "center";
1190
+ if (value === "top" || value === "bottom") return value;
1191
+ return undefined;
1192
+ }
1193
+
1194
+ function colorFromExcel(color: Partial<ExcelJS.Color> | undefined): string | undefined {
1195
+ const argb = color?.argb;
1196
+ if (!argb) return undefined;
1197
+ const normalized = argb.replace(/^#/, "");
1198
+ if (/^[0-9a-f]{8}$/i.test(normalized)) return `#${normalized.slice(2)}`;
1199
+ if (/^[0-9a-f]{6}$/i.test(normalized)) return `#${normalized}`;
1200
+ return undefined;
1201
+ }
1202
+
1203
+ function colorToArgb(color: string): string {
1204
+ const normalized = color.trim().replace(/^#/, "");
1205
+ if (/^[0-9a-f]{8}$/i.test(normalized)) return normalized.toUpperCase();
1206
+ if (/^[0-9a-f]{6}$/i.test(normalized)) return `FF${normalized.toUpperCase()}`;
1207
+ if (/^[0-9a-f]{3}$/i.test(normalized)) {
1208
+ return `FF${normalized
1209
+ .split("")
1210
+ .map((character) => character.repeat(2))
1211
+ .join("")}`.toUpperCase();
1212
+ }
1213
+ return "FF000000";
1214
+ }
1215
+
1216
+ function excelBorderStyle(value: string | undefined): ExcelJS.BorderStyle {
1217
+ const supported: readonly ExcelJS.BorderStyle[] = [
1218
+ "thin",
1219
+ "dotted",
1220
+ "hair",
1221
+ "medium",
1222
+ "double",
1223
+ "thick",
1224
+ "dashed",
1225
+ "dashDot",
1226
+ "dashDotDot",
1227
+ "slantDashDot",
1228
+ "mediumDashed",
1229
+ "mediumDashDotDot",
1230
+ "mediumDashDot",
1231
+ ];
1232
+ return supported.includes(value as ExcelJS.BorderStyle) ? (value as ExcelJS.BorderStyle) : "thin";
1233
+ }
1234
+
1235
+ function excelTableTheme(value: string): NonNullable<ExcelJS.TableStyleProperties["theme"]> {
1236
+ return /^TableStyle(?:Dark|Light|Medium)\d+$/.test(value)
1237
+ ? (value as NonNullable<ExcelJS.TableStyleProperties["theme"]>)
1238
+ : "TableStyleMedium2";
1239
+ }
1240
+
1241
+ function excelWidthToPixels(width: number): number {
1242
+ return Math.max(1, Math.round(width * 7 + 5));
1243
+ }
1244
+
1245
+ function pixelsToExcelWidth(pixels: number): number {
1246
+ return Math.max(0.1, Number(((pixels - 5) / 7).toFixed(2)));
1247
+ }
1248
+
1249
+ function pointsToPixels(points: number): number {
1250
+ return points * (96 / 72);
1251
+ }
1252
+
1253
+ function pixelsToPoints(pixels: number): number {
1254
+ return Number((pixels * (72 / 96)).toFixed(2));
1255
+ }
1256
+
1257
+ function copyArrayBuffer(value: ArrayBuffer): ArrayBuffer {
1258
+ return new Uint8Array(value).slice().buffer;
1259
+ }
1260
+
1261
+ function isFormulaError(value: string): value is FormulaErrorValue {
1262
+ return ["#DIV/0!", "#N/A", "#NAME?", "#NUM!", "#REF!", "#VALUE!", "#CYCLE!"].includes(value);
1263
+ }
1264
+
1265
+ function forEachCell(address: RangeAddress, callback: (row: number, col: number) => void): void {
1266
+ for (let row = address.row; row < address.row + address.rowCount; row += 1) {
1267
+ for (let col = address.col; col < address.col + address.colCount; col += 1) callback(row, col);
1268
+ }
1269
+ }
1270
+
1271
+ async function toBytes(
1272
+ input: FileBlob | Blob | ArrayBuffer | Uint8Array,
1273
+ maxBytes: number,
1274
+ ): Promise<Uint8Array> {
1275
+ if (input instanceof Uint8Array) {
1276
+ if (input.byteLength > maxBytes) {
1277
+ throw securityError("XLSX exceeds the compressed-size limit", "limit-exceeded");
1278
+ }
1279
+ return Uint8Array.prototype.slice.call(input) as Uint8Array;
1280
+ }
1281
+ if (input instanceof ArrayBuffer) {
1282
+ if (input.byteLength > maxBytes) {
1283
+ throw securityError("XLSX exceeds the compressed-size limit", "limit-exceeded");
1284
+ }
1285
+ return new Uint8Array(input.slice(0));
1286
+ }
1287
+ if (typeof Blob !== "undefined" && input instanceof Blob) {
1288
+ const size = Object.getOwnPropertyDescriptor(Blob.prototype, "size")?.get?.call(input);
1289
+ if (typeof size !== "number" || size > maxBytes) {
1290
+ throw securityError("XLSX exceeds the compressed-size limit", "limit-exceeded");
1291
+ }
1292
+ return new Uint8Array(await Blob.prototype.arrayBuffer.call(input));
1293
+ }
1294
+ throw new TypeError("XLSX input must be a Blob, ArrayBuffer, or Uint8Array");
1295
+ }
1296
+
1297
+ async function workbookModelDigest(workbook: Workbook): Promise<string> {
1298
+ return sha256Hex(new TextEncoder().encode(JSON.stringify(workbook.toJSON())));
1299
+ }
1300
+
1301
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
1302
+ const owned = new Uint8Array(bytes.byteLength);
1303
+ owned.set(bytes);
1304
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", owned));
1305
+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1306
+ }
1307
+
1308
+ /** ExcelJS stamps ZIP headers with wall-clock time; normalize package metadata only. */
1309
+ function normalizeGeneratedZip(input: Uint8Array): Uint8Array {
1310
+ const bytes = Uint8Array.from(input);
1311
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1312
+ const minimumEocdBytes = 22;
1313
+ if (bytes.byteLength < minimumEocdBytes) {
1314
+ throw new Error("Generated XLSX ZIP has no end-of-central-directory record");
1315
+ }
1316
+ const firstCandidate = Math.max(0, bytes.byteLength - 65_557);
1317
+ let eocdOffset = -1;
1318
+ for (let offset = bytes.byteLength - minimumEocdBytes; offset >= firstCandidate; offset -= 1) {
1319
+ if (
1320
+ view.getUint32(offset, true) === 0x06054b50 &&
1321
+ offset + minimumEocdBytes + view.getUint16(offset + 20, true) === bytes.byteLength
1322
+ ) {
1323
+ eocdOffset = offset;
1324
+ break;
1325
+ }
1326
+ }
1327
+ if (eocdOffset < 0) {
1328
+ throw new Error("Generated XLSX ZIP has an invalid end-of-central-directory record");
1329
+ }
1330
+ const disk = view.getUint16(eocdOffset + 4, true);
1331
+ const centralDisk = view.getUint16(eocdOffset + 6, true);
1332
+ const diskEntries = view.getUint16(eocdOffset + 8, true);
1333
+ const entries = view.getUint16(eocdOffset + 10, true);
1334
+ const centralBytes = view.getUint32(eocdOffset + 12, true);
1335
+ const centralOffset = view.getUint32(eocdOffset + 16, true);
1336
+ if (
1337
+ disk !== 0 ||
1338
+ centralDisk !== 0 ||
1339
+ diskEntries !== entries ||
1340
+ entries === 0xffff ||
1341
+ centralBytes === 0xffff_ffff ||
1342
+ centralOffset === 0xffff_ffff ||
1343
+ centralOffset + centralBytes !== eocdOffset
1344
+ ) {
1345
+ throw new Error("Generated XLSX ZIP uses an unsupported central-directory layout");
1346
+ }
1347
+ let offset = centralOffset;
1348
+ for (let index = 0; index < entries; index += 1) {
1349
+ if (offset + 46 > eocdOffset || view.getUint32(offset, true) !== 0x02014b50) {
1350
+ throw new Error("Generated XLSX ZIP central directory is malformed");
1351
+ }
1352
+ const nameBytes = view.getUint16(offset + 28, true);
1353
+ const extraBytes = view.getUint16(offset + 30, true);
1354
+ const commentBytes = view.getUint16(offset + 32, true);
1355
+ const localOffset = view.getUint32(offset + 42, true);
1356
+ const nextOffset = offset + 46 + nameBytes + extraBytes + commentBytes;
1357
+ if (
1358
+ nextOffset > eocdOffset ||
1359
+ localOffset + 30 > centralOffset ||
1360
+ view.getUint32(localOffset, true) !== 0x04034b50
1361
+ ) {
1362
+ throw new Error("Generated XLSX ZIP entry offsets are malformed");
1363
+ }
1364
+ const localNameBytes = view.getUint16(localOffset + 26, true);
1365
+ const localExtraBytes = view.getUint16(localOffset + 28, true);
1366
+ const localExtraOffset = localOffset + 30 + localNameBytes;
1367
+ if (localExtraOffset + localExtraBytes > centralOffset) {
1368
+ throw new Error("Generated XLSX ZIP local metadata is malformed");
1369
+ }
1370
+ // Portable DOS 1980-01-01 00:00. File bytes and CRCs are untouched.
1371
+ view.setUint16(offset + 12, 0, true);
1372
+ view.setUint16(offset + 14, 0x21, true);
1373
+ view.setUint16(localOffset + 10, 0, true);
1374
+ view.setUint16(localOffset + 12, 0x21, true);
1375
+ normalizeZipTimestampExtras(view, offset + 46 + nameBytes, extraBytes);
1376
+ normalizeZipTimestampExtras(view, localExtraOffset, localExtraBytes);
1377
+ offset = nextOffset;
1378
+ }
1379
+ if (offset !== eocdOffset) {
1380
+ throw new Error("Generated XLSX ZIP central-directory size is inconsistent");
1381
+ }
1382
+ return bytes;
1383
+ }
1384
+
1385
+ function normalizeZipTimestampExtras(view: DataView, start: number, length: number): void {
1386
+ const end = start + length;
1387
+ let offset = start;
1388
+ while (offset < end) {
1389
+ if (offset + 4 > end) throw new Error("Generated XLSX ZIP extra field is malformed");
1390
+ const kind = view.getUint16(offset, true);
1391
+ const fieldBytes = view.getUint16(offset + 2, true);
1392
+ const dataStart = offset + 4;
1393
+ const dataEnd = dataStart + fieldBytes;
1394
+ if (dataEnd > end) throw new Error("Generated XLSX ZIP extra field is malformed");
1395
+ if (kind === 0x5455 && fieldBytes > 0) {
1396
+ const flags = view.getUint8(dataStart);
1397
+ let timestampOffset = dataStart + 1;
1398
+ for (const flag of [1, 2, 4]) {
1399
+ if ((flags & flag) === 0) continue;
1400
+ if (timestampOffset + 4 > dataEnd) {
1401
+ throw new Error("Generated XLSX ZIP timestamp field is malformed");
1402
+ }
1403
+ view.setUint32(timestampOffset, 315_532_800, true);
1404
+ timestampOffset += 4;
1405
+ }
1406
+ }
1407
+ offset = dataEnd;
1408
+ }
1409
+ }
1410
+
1411
+ type ZipEntry = BoundedZipEntry;
1412
+
1413
+ async function preflightXlsx(
1414
+ bytes: Uint8Array,
1415
+ limits: SpreadsheetXlsxImportLimits,
1416
+ ): Promise<{
1417
+ opaqueContent: SpreadsheetOpaqueContent;
1418
+ comments: readonly ImportedLegacyComment[];
1419
+ }> {
1420
+ if (bytes.byteLength > limits.compressedBytes) {
1421
+ throw securityError(
1422
+ `XLSX exceeds the ${limits.compressedBytes} byte compressed-size limit`,
1423
+ "limit-exceeded",
1424
+ );
1425
+ }
1426
+ const entries = zipEntries(bytes, limits);
1427
+ const entryNames = new Map(entries.map((entry) => [entry.name.toLowerCase(), entry.name]));
1428
+ for (const required of [
1429
+ "[content_types].xml",
1430
+ "_rels/.rels",
1431
+ "xl/workbook.xml",
1432
+ "xl/_rels/workbook.xml.rels",
1433
+ ]) {
1434
+ if (!entryNames.has(required)) {
1435
+ throw securityError("Required XLSX package part is missing", "invalid-package", required);
1436
+ }
1437
+ }
1438
+ let expandedBytes = 0;
1439
+ let inspectedXmlBytes = 0;
1440
+ let xmlAttributes = 0;
1441
+ let xmlElements = 0;
1442
+ let xmlTextCharacters = 0;
1443
+ let worksheetCells = 0;
1444
+ let formulas = 0;
1445
+ let formulaBytes = 0;
1446
+ let sharedStrings = 0;
1447
+ let cellStyles = 0;
1448
+ let styleRecords = 0;
1449
+ let comments = 0;
1450
+ let relationships = 0;
1451
+ let mediaEntries = 0;
1452
+ let mediaBytes = 0;
1453
+ let imagePixels = 0;
1454
+ const allRelationships: InspectedRelationship[] = [];
1455
+ const contentTypeDeclarations: ContentTypeDeclaration[] = [];
1456
+ const workbookSheets: WorkbookSheetBinding[] = [];
1457
+ const legacyCommentParts: Array<{
1458
+ partName: string;
1459
+ comments: LegacyCommentRecord[];
1460
+ }> = [];
1461
+ const opaqueParts = new Set<string>();
1462
+ const opaqueFeatures = new Set<string>();
1463
+ const mediaTypes = new Map<string, string>();
1464
+ for (const entry of entries) {
1465
+ if (entry.directory) {
1466
+ await verifyBoundedZipEntry(bytes, entry, 0, xlsxZipFailure);
1467
+ continue;
1468
+ }
1469
+ expandedBytes += entry.expandedSize;
1470
+ if (expandedBytes > limits.expandedBytes) {
1471
+ throw securityError(
1472
+ `XLSX expands beyond the ${limits.expandedBytes} byte package limit`,
1473
+ "limit-exceeded",
1474
+ entry.name,
1475
+ );
1476
+ }
1477
+ if (entry.expandedSize > limits.entryBytes) {
1478
+ throw securityError(
1479
+ `XLSX entry exceeds the ${limits.entryBytes} byte entry limit`,
1480
+ "limit-exceeded",
1481
+ entry.name,
1482
+ );
1483
+ }
1484
+ if (
1485
+ entry.expandedSize > 1_048_576 &&
1486
+ entry.expandedSize / Math.max(1, entry.compressedSize) > limits.compressionRatio
1487
+ ) {
1488
+ throw securityError(
1489
+ `XLSX entry exceeds the ${limits.compressionRatio}:1 compression-ratio limit`,
1490
+ "limit-exceeded",
1491
+ entry.name,
1492
+ );
1493
+ }
1494
+ rejectActiveEntry(entry.name);
1495
+ if (isMediaPart(entry.name)) {
1496
+ mediaEntries += 1;
1497
+ mediaBytes += entry.expandedSize;
1498
+ if (
1499
+ mediaEntries > limits.mediaEntries ||
1500
+ entry.expandedSize > limits.mediaEntryBytes ||
1501
+ mediaBytes > limits.mediaBytes
1502
+ ) {
1503
+ throw securityError(
1504
+ "Spreadsheet media exceeds its bounded import limits",
1505
+ "limit-exceeded",
1506
+ entry.name,
1507
+ );
1508
+ }
1509
+ const media = await inflateBoundedZipEntry(
1510
+ bytes,
1511
+ entry,
1512
+ limits.mediaEntryBytes,
1513
+ xlsxZipFailure,
1514
+ );
1515
+ let metadata: ReturnType<typeof inspectRasterImage>;
1516
+ try {
1517
+ metadata = inspectRasterImage(media);
1518
+ } catch (error) {
1519
+ const failure = securityError(
1520
+ `Spreadsheet media is not a structurally valid bounded raster image (${error instanceof Error ? error.message : "invalid image"})`,
1521
+ "active-content",
1522
+ entry.name,
1523
+ );
1524
+ Object.defineProperty(failure, "cause", {
1525
+ value: error,
1526
+ enumerable: false,
1527
+ });
1528
+ throw failure;
1529
+ }
1530
+ const expectedType = mediaTypeForExtension(entry.name);
1531
+ if (metadata.contentType !== expectedType) {
1532
+ throw securityError(
1533
+ "Spreadsheet media MIME does not match its file signature",
1534
+ "active-content",
1535
+ entry.name,
1536
+ );
1537
+ }
1538
+ imagePixels += metadata.width * metadata.height;
1539
+ if (imagePixels > limits.imagePixels) {
1540
+ throw securityError(
1541
+ "Spreadsheet images exceed their decoded-pixel limit",
1542
+ "limit-exceeded",
1543
+ entry.name,
1544
+ );
1545
+ }
1546
+ mediaTypes.set(entry.name.toLowerCase(), metadata.contentType);
1547
+ continue;
1548
+ }
1549
+ if (!isInspectableXml(entry.name)) {
1550
+ await verifyBoundedZipEntry(bytes, entry, limits.entryBytes, xlsxZipFailure);
1551
+ throw securityError(
1552
+ "XLSX part is neither modeled nor in the bounded inert OOXML allowlist",
1553
+ "active-content",
1554
+ entry.name,
1555
+ );
1556
+ }
1557
+ const disposition = partDisposition(entry.name);
1558
+ inspectedXmlBytes += entry.expandedSize;
1559
+ if (inspectedXmlBytes > limits.inspectedXmlBytes) {
1560
+ throw securityError(
1561
+ `Inspectable OOXML exceeds the ${limits.inspectedXmlBytes} byte inspection limit`,
1562
+ "limit-exceeded",
1563
+ entry.name,
1564
+ );
1565
+ }
1566
+ const xmlBytes = await inflateBoundedZipEntry(bytes, entry, limits.entryBytes, xlsxZipFailure);
1567
+ const xml = decodeXml(xmlBytes, entry.name);
1568
+ const counts = inspectXml(xml, entry.name, limits, entryNames);
1569
+ xmlAttributes += counts.attributes;
1570
+ xmlElements += counts.elements;
1571
+ xmlTextCharacters += counts.textCharacters;
1572
+ worksheetCells += counts.worksheetCells;
1573
+ formulas += counts.formulas;
1574
+ formulaBytes += counts.formulaBytes;
1575
+ sharedStrings += counts.sharedStrings;
1576
+ cellStyles += counts.cellStyles;
1577
+ styleRecords += counts.styleRecords;
1578
+ comments += counts.comments.length;
1579
+ relationships += counts.relationships.length;
1580
+ allRelationships.push(...counts.relationships);
1581
+ contentTypeDeclarations.push(...counts.contentTypes);
1582
+ workbookSheets.push(...counts.workbookSheets);
1583
+ if (counts.comments.length > 0) {
1584
+ legacyCommentParts.push({
1585
+ partName: entry.name,
1586
+ comments: counts.comments,
1587
+ });
1588
+ }
1589
+ for (const feature of counts.opaqueFeatures) {
1590
+ opaqueFeatures.add(feature);
1591
+ opaqueParts.add(entry.name);
1592
+ }
1593
+ if (xmlAttributes > limits.xmlAttributes) {
1594
+ throw securityError(
1595
+ `OOXML package exceeds the ${limits.xmlAttributes} attribute limit`,
1596
+ "limit-exceeded",
1597
+ entry.name,
1598
+ );
1599
+ }
1600
+ if (relationships > limits.relationships) {
1601
+ throw securityError(
1602
+ `OOXML package exceeds the ${limits.relationships} relationship limit`,
1603
+ "limit-exceeded",
1604
+ entry.name,
1605
+ );
1606
+ }
1607
+ if (xmlElements > limits.xmlElements) {
1608
+ throw securityError(
1609
+ `OOXML package exceeds the ${limits.xmlElements} element limit`,
1610
+ "limit-exceeded",
1611
+ entry.name,
1612
+ );
1613
+ }
1614
+ if (xmlTextCharacters > limits.xmlTextCharacters) {
1615
+ throw securityError(
1616
+ `OOXML package exceeds the ${limits.xmlTextCharacters} text-character limit`,
1617
+ "limit-exceeded",
1618
+ entry.name,
1619
+ );
1620
+ }
1621
+ if (worksheetCells > limits.worksheetCells) {
1622
+ throw securityError(
1623
+ `OOXML package exceeds the ${limits.worksheetCells} worksheet-cell limit`,
1624
+ "limit-exceeded",
1625
+ entry.name,
1626
+ );
1627
+ }
1628
+ if (sharedStrings > limits.sharedStrings) {
1629
+ throw securityError(
1630
+ `OOXML package exceeds the ${limits.sharedStrings} shared-string limit`,
1631
+ "limit-exceeded",
1632
+ entry.name,
1633
+ );
1634
+ }
1635
+ if (cellStyles > limits.cellStyles) {
1636
+ throw securityError(
1637
+ `OOXML package exceeds the ${limits.cellStyles} cell-style limit`,
1638
+ "limit-exceeded",
1639
+ entry.name,
1640
+ );
1641
+ }
1642
+ if (
1643
+ formulas > limits.formulas ||
1644
+ formulaBytes > limits.formulaBytes ||
1645
+ styleRecords > limits.styleRecords
1646
+ ) {
1647
+ throw securityError(
1648
+ "OOXML package exceeds formula or style aggregate limits",
1649
+ "limit-exceeded",
1650
+ entry.name,
1651
+ );
1652
+ }
1653
+ if (comments > limits.comments) {
1654
+ throw securityError(
1655
+ `OOXML package exceeds the ${limits.comments} comment limit`,
1656
+ "limit-exceeded",
1657
+ entry.name,
1658
+ );
1659
+ }
1660
+ if (disposition === "reject") {
1661
+ throw securityError(
1662
+ "XLSX XML part is neither modeled nor in the bounded inert allowlist",
1663
+ "active-content",
1664
+ entry.name,
1665
+ );
1666
+ }
1667
+ if (disposition === "opaque") {
1668
+ opaqueParts.add(entry.name);
1669
+ opaqueFeatures.add(opaqueFeatureForPart(entry.name));
1670
+ }
1671
+ }
1672
+ const opaqueRelationships: SpreadsheetOpaqueRelationship[] = [];
1673
+ for (const relationship of allRelationships) {
1674
+ if (
1675
+ relationship.opaque ||
1676
+ opaqueParts.has(relationship.sourcePart) ||
1677
+ opaqueParts.has(relationship.targetPart)
1678
+ ) {
1679
+ const { opaque: _opaque, id: _id, kind: _kind, ...metadata } = relationship;
1680
+ opaqueRelationships.push(metadata);
1681
+ opaqueParts.add(relationshipPartForSource(relationship.sourcePart));
1682
+ }
1683
+ }
1684
+ const contentTypes = resolveContentTypes(
1685
+ entries,
1686
+ contentTypeDeclarations,
1687
+ mediaTypes,
1688
+ opaqueParts,
1689
+ );
1690
+ const importedComments = resolveLegacyComments(
1691
+ workbookSheets,
1692
+ allRelationships,
1693
+ legacyCommentParts,
1694
+ );
1695
+ return {
1696
+ opaqueContent: {
1697
+ parts: [...opaqueParts].sort(compareStrings),
1698
+ relationships: opaqueRelationships.sort(compareOpaqueRelationships),
1699
+ contentTypes: contentTypes
1700
+ .filter((contentType) => opaqueParts.has(contentType.partName))
1701
+ .sort(compareOpaqueContentTypes),
1702
+ features: [...opaqueFeatures].sort(compareStrings),
1703
+ },
1704
+ comments: importedComments,
1705
+ };
1706
+ }
1707
+
1708
+ function resolveLegacyComments(
1709
+ workbookSheets: readonly WorkbookSheetBinding[],
1710
+ relationships: readonly InspectedRelationship[],
1711
+ parts: readonly {
1712
+ partName: string;
1713
+ comments: readonly LegacyCommentRecord[];
1714
+ }[],
1715
+ ): ImportedLegacyComment[] {
1716
+ const worksheetPartsByRelationship = new Map(
1717
+ relationships
1718
+ .filter(
1719
+ (relationship) =>
1720
+ relationship.sourcePart.toLowerCase() === "xl/workbook.xml" &&
1721
+ relationship.kind === "worksheet",
1722
+ )
1723
+ .map((relationship) => [relationship.id, relationship.targetPart]),
1724
+ );
1725
+ const sheetNamesByPart = new Map<string, string>();
1726
+ for (const sheet of workbookSheets) {
1727
+ const partName = worksheetPartsByRelationship.get(sheet.relationshipId);
1728
+ if (!partName || sheetNamesByPart.has(partName.toLowerCase())) {
1729
+ throw securityError(
1730
+ "Workbook sheet relationship is missing or duplicated",
1731
+ "invalid-package",
1732
+ "xl/workbook.xml",
1733
+ );
1734
+ }
1735
+ sheetNamesByPart.set(partName.toLowerCase(), sheet.name);
1736
+ }
1737
+
1738
+ const sheetPartsByCommentPart = new Map<string, string>();
1739
+ for (const relationship of relationships) {
1740
+ if (relationship.kind !== "comments") continue;
1741
+ const target = relationship.targetPart.toLowerCase();
1742
+ if (sheetPartsByCommentPart.has(target)) {
1743
+ throw securityError(
1744
+ "Spreadsheet comment part is linked from multiple worksheets",
1745
+ "invalid-package",
1746
+ relationship.targetPart,
1747
+ );
1748
+ }
1749
+ sheetPartsByCommentPart.set(target, relationship.sourcePart);
1750
+ }
1751
+
1752
+ const result: ImportedLegacyComment[] = [];
1753
+ const cells = new Set<string>();
1754
+ for (const part of parts) {
1755
+ const sheetPart = sheetPartsByCommentPart.get(part.partName.toLowerCase());
1756
+ if (!sheetPart) continue;
1757
+ const sheetName = sheetNamesByPart.get(sheetPart.toLowerCase());
1758
+ if (!sheetName) {
1759
+ throw securityError(
1760
+ "Spreadsheet comment relationship does not target a workbook sheet",
1761
+ "invalid-package",
1762
+ part.partName,
1763
+ );
1764
+ }
1765
+ for (const comment of part.comments) {
1766
+ const key = `${sheetName}\u0000${comment.cell}`;
1767
+ if (cells.has(key)) {
1768
+ throw securityError(
1769
+ "Spreadsheet has duplicate comments for one cell",
1770
+ "invalid-package",
1771
+ part.partName,
1772
+ );
1773
+ }
1774
+ cells.add(key);
1775
+ result.push({ sheetName, cell: comment.cell, text: comment.text });
1776
+ }
1777
+ }
1778
+ return result;
1779
+ }
1780
+
1781
+ function isMediaPart(name: string): boolean {
1782
+ return /^xl\/media\/[^/]+\.(?:png|jpe?g|gif)$/i.test(name);
1783
+ }
1784
+
1785
+ function mediaTypeForExtension(name: string): "image/png" | "image/jpeg" | "image/gif" {
1786
+ if (/\.png$/i.test(name)) return "image/png";
1787
+ if (/\.jpe?g$/i.test(name)) return "image/jpeg";
1788
+ if (/\.gif$/i.test(name)) return "image/gif";
1789
+ throw securityError("Spreadsheet media extension is unsupported", "active-content", name);
1790
+ }
1791
+
1792
+ function partDisposition(name: string): "modeled" | "opaque" | "reject" {
1793
+ if (
1794
+ name === "[Content_Types].xml" ||
1795
+ name === "_rels/.rels" ||
1796
+ name === "xl/workbook.xml" ||
1797
+ name === "xl/_rels/workbook.xml.rels" ||
1798
+ /^xl\/worksheets\/[^/]+\.xml$/i.test(name) ||
1799
+ /^xl\/worksheets\/_rels\/[^/]+\.xml\.rels$/i.test(name) ||
1800
+ /^xl\/(?:styles|sharedStrings|calcChain)\.xml$/i.test(name) ||
1801
+ /^xl\/tables\/[^/]+\.xml$/i.test(name) ||
1802
+ /^xl\/drawings\/[^/]+\.xml$/i.test(name) ||
1803
+ /^xl\/drawings\/_rels\/[^/]+\.xml\.rels$/i.test(name) ||
1804
+ /^xl\/comments[^/]*\.xml$/i.test(name) ||
1805
+ /^xl\/drawings\/[^/]+\.vml$/i.test(name)
1806
+ ) {
1807
+ return "modeled";
1808
+ }
1809
+ if (
1810
+ /^docProps\/custom\.xml$/i.test(name) ||
1811
+ /^xl\/charts\/[^/]+\.xml$/i.test(name) ||
1812
+ /^xl\/charts\/_rels\/[^/]+\.xml\.rels$/i.test(name) ||
1813
+ /^customXml\/item(?:Props)?\d+\.xml$/i.test(name) ||
1814
+ /^customXml\/_rels\/item\d+\.xml\.rels$/i.test(name)
1815
+ ) {
1816
+ return "opaque";
1817
+ }
1818
+ if (/^docProps\/(?:core|app)\.xml$/i.test(name) || /^xl\/theme\/[^/]+\.xml$/i.test(name)) {
1819
+ return "modeled";
1820
+ }
1821
+ return "reject";
1822
+ }
1823
+
1824
+ function opaqueFeatureForPart(name: string): string {
1825
+ if (/^docProps\//i.test(name)) return "workbook-properties";
1826
+ if (/^xl\/theme\//i.test(name)) return "workbook-theme";
1827
+ if (/^xl\/charts\//i.test(name)) return "editable-charts";
1828
+ if (/^customXml\//i.test(name)) return "custom-xml";
1829
+ return "opaque-ooxml";
1830
+ }
1831
+
1832
+ function resolveContentTypes(
1833
+ entries: readonly ZipEntry[],
1834
+ declarations: readonly ContentTypeDeclaration[],
1835
+ mediaTypes: ReadonlyMap<string, string>,
1836
+ opaqueParts: Set<string>,
1837
+ ): SpreadsheetOpaqueContentType[] {
1838
+ const defaults = new Map<string, string>();
1839
+ const overrides = new Map<string, string>();
1840
+ const existing = new Set(
1841
+ entries.filter((entry) => !entry.directory).map((entry) => entry.name.toLowerCase()),
1842
+ );
1843
+ for (const declaration of declarations) {
1844
+ if (declaration.kind === "default") {
1845
+ if (defaults.has(declaration.extension)) {
1846
+ throw securityError(
1847
+ "XLSX has duplicate default content types",
1848
+ "invalid-package",
1849
+ "[Content_Types].xml",
1850
+ );
1851
+ }
1852
+ defaults.set(declaration.extension, declaration.contentType);
1853
+ } else {
1854
+ const key = declaration.partName.toLowerCase();
1855
+ if (overrides.has(key) || !existing.has(key)) {
1856
+ throw securityError(
1857
+ "XLSX has duplicate or dangling content type overrides",
1858
+ "invalid-package",
1859
+ declaration.partName,
1860
+ );
1861
+ }
1862
+ overrides.set(key, declaration.contentType);
1863
+ }
1864
+ }
1865
+ const output: SpreadsheetOpaqueContentType[] = [];
1866
+ for (const entry of entries) {
1867
+ if (entry.directory || entry.name === "[Content_Types].xml") continue;
1868
+ const extension = entry.name.includes(".")
1869
+ ? entry.name.slice(entry.name.lastIndexOf(".") + 1).toLowerCase()
1870
+ : "";
1871
+ const contentType = overrides.get(entry.name.toLowerCase()) ?? defaults.get(extension);
1872
+ if (!contentType) {
1873
+ throw securityError("XLSX part has no declared content type", "invalid-package", entry.name);
1874
+ }
1875
+ rejectActiveContentType(contentType, entry.name);
1876
+ if (/\.rels$/i.test(entry.name)) {
1877
+ if (contentType !== "application/vnd.openxmlformats-package.relationships+xml") {
1878
+ throw securityError("Relationship part MIME is inconsistent", "active-content", entry.name);
1879
+ }
1880
+ } else if (isMediaPart(entry.name)) {
1881
+ if (contentType !== mediaTypes.get(entry.name.toLowerCase())) {
1882
+ throw securityError(
1883
+ "Spreadsheet media content type is MIME-confused",
1884
+ "active-content",
1885
+ entry.name,
1886
+ );
1887
+ }
1888
+ } else if (/\.vml$/i.test(entry.name)) {
1889
+ if (contentType !== "application/vnd.openxmlformats-officedocument.vmlDrawing") {
1890
+ throw securityError("VML content type is inconsistent", "active-content", entry.name);
1891
+ }
1892
+ } else if (isInspectableXml(entry.name) && !isXmlContentType(contentType)) {
1893
+ throw securityError("XML part has a non-XML content type", "active-content", entry.name);
1894
+ }
1895
+ if (opaqueParts.has(entry.name)) output.push({ partName: entry.name, contentType });
1896
+ }
1897
+ return output;
1898
+ }
1899
+
1900
+ function isXmlContentType(contentType: string): boolean {
1901
+ return (
1902
+ contentType === "application/xml" ||
1903
+ contentType === "text/xml" ||
1904
+ /\+xml(?:;|$)/i.test(contentType)
1905
+ );
1906
+ }
1907
+
1908
+ function isCanonicalPartName(name: string): boolean {
1909
+ return (
1910
+ name.length > 0 &&
1911
+ name.length <= 2_048 &&
1912
+ !name.startsWith("/") &&
1913
+ !name.endsWith("/") &&
1914
+ !name.includes("\\") &&
1915
+ !name.includes("%") &&
1916
+ !/[\u0000-\u001f\u007f]/.test(name) &&
1917
+ name.normalize("NFC") === name &&
1918
+ name.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..")
1919
+ );
1920
+ }
1921
+
1922
+ function relationshipPartForSource(sourcePart: string): string {
1923
+ if (sourcePart === "") return "_rels/.rels";
1924
+ const slash = sourcePart.lastIndexOf("/");
1925
+ const directory = slash < 0 ? "" : sourcePart.slice(0, slash + 1);
1926
+ const file = sourcePart.slice(slash + 1);
1927
+ return `${directory}_rels/${file}.rels`;
1928
+ }
1929
+
1930
+ function compareStrings(left: string, right: string): number {
1931
+ return left < right ? -1 : left > right ? 1 : 0;
1932
+ }
1933
+
1934
+ function compareOpaqueRelationships(
1935
+ left: SpreadsheetOpaqueRelationship,
1936
+ right: SpreadsheetOpaqueRelationship,
1937
+ ): number {
1938
+ return (
1939
+ compareStrings(left.sourcePart, right.sourcePart) ||
1940
+ compareStrings(left.type, right.type) ||
1941
+ compareStrings(left.targetPart, right.targetPart)
1942
+ );
1943
+ }
1944
+
1945
+ function compareOpaqueContentTypes(
1946
+ left: SpreadsheetOpaqueContentType,
1947
+ right: SpreadsheetOpaqueContentType,
1948
+ ): number {
1949
+ return (
1950
+ compareStrings(left.partName, right.partName) ||
1951
+ compareStrings(left.contentType, right.contentType)
1952
+ );
1953
+ }
1954
+
1955
+ function resolveImportLimits(
1956
+ options: Partial<SpreadsheetXlsxImportLimits> | undefined,
1957
+ ): SpreadsheetXlsxImportLimits {
1958
+ const resolved = { ...DEFAULT_XLSX_IMPORT_LIMITS, ...options };
1959
+ for (const [name, value] of Object.entries(resolved)) {
1960
+ if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) {
1961
+ throw new TypeError(`XLSX import limit ${name} must be a positive integer`);
1962
+ }
1963
+ }
1964
+ // Public callers may reduce work budgets for their deployment, but must not
1965
+ // expand the audited hard envelope before ZIP/XML preflight.
1966
+ for (const [name, maximum] of Object.entries(DEFAULT_XLSX_IMPORT_LIMITS) as Array<
1967
+ [keyof SpreadsheetXlsxImportLimits, number]
1968
+ >) {
1969
+ if (resolved[name] > maximum) {
1970
+ throw new TypeError(
1971
+ `XLSX import limit ${name} cannot exceed its hard safety cap of ${maximum}`,
1972
+ );
1973
+ }
1974
+ }
1975
+ return resolved;
1976
+ }
1977
+
1978
+ function zipEntries(bytes: Uint8Array, limits: SpreadsheetXlsxImportLimits): ZipEntry[] {
1979
+ return parseBoundedZip(
1980
+ bytes,
1981
+ {
1982
+ entries: limits.entries,
1983
+ compressedEntryBytes: limits.compressedBytes,
1984
+ expandedEntryBytes: limits.entryBytes,
1985
+ expandedBytes: limits.expandedBytes,
1986
+ compressionRatio: limits.compressionRatio,
1987
+ compressionRatioThreshold: 1_048_576,
1988
+ },
1989
+ xlsxZipFailure,
1990
+ );
1991
+ }
1992
+
1993
+ function rejectActiveEntry(name: string): void {
1994
+ if (
1995
+ /(?:^|\/)(?:vbaProject|vbaData)\.bin$/i.test(name) ||
1996
+ /(?:^|\/)(?:activeX|embeddings|externalLinks|queryTables|ctrlProps|webextensions)(?:\/|$)/i.test(
1997
+ name,
1998
+ ) ||
1999
+ /^xl\/connections\.xml$/i.test(name) ||
2000
+ /\.(?:exe|dll|com|msi|js|vbs|vbe|bat|cmd|ps1|sh|scr|jar)$/i.test(name)
2001
+ ) {
2002
+ throw securityError(
2003
+ "Active or externally connected OOXML content is unsupported",
2004
+ "active-content",
2005
+ name,
2006
+ );
2007
+ }
2008
+ }
2009
+
2010
+ function isInspectableXml(name: string): boolean {
2011
+ return /(?:\.xml|\.rels|\.vml)$/i.test(name) || /^\[Content_Types\]\.xml$/i.test(name);
2012
+ }
2013
+
2014
+ function decodeXml(bytes: Uint8Array, entryName: string): string {
2015
+ try {
2016
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
2017
+ return new TextDecoder("utf-16le", { fatal: true }).decode(bytes);
2018
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
2019
+ return new TextDecoder("utf-16be", { fatal: true }).decode(bytes);
2020
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
2021
+ } catch {
2022
+ throw securityError("OOXML text is not valid UTF-8/UTF-16", "unsafe-xml", entryName);
2023
+ }
2024
+ }
2025
+
2026
+ type ContentTypeDeclaration =
2027
+ | { kind: "default"; extension: string; contentType: string }
2028
+ | { kind: "override"; partName: string; contentType: string };
2029
+
2030
+ type InspectedRelationship = SpreadsheetOpaqueRelationship & {
2031
+ id: string;
2032
+ kind: string;
2033
+ opaque: boolean;
2034
+ };
2035
+
2036
+ type WorkbookSheetBinding = { name: string; relationshipId: string };
2037
+ type LegacyCommentRecord = { cell: string; text: string };
2038
+
2039
+ type XmlInspection = {
2040
+ attributes: number;
2041
+ elements: number;
2042
+ textCharacters: number;
2043
+ worksheetCells: number;
2044
+ formulas: number;
2045
+ formulaBytes: number;
2046
+ sharedStrings: number;
2047
+ cellStyles: number;
2048
+ styleRecords: number;
2049
+ workbookSheets: WorkbookSheetBinding[];
2050
+ comments: LegacyCommentRecord[];
2051
+ relationships: InspectedRelationship[];
2052
+ contentTypes: ContentTypeDeclaration[];
2053
+ opaqueFeatures: string[];
2054
+ };
2055
+
2056
+ const FORMULA_ELEMENTS = new Set([
2057
+ "f",
2058
+ "formula",
2059
+ "formula1",
2060
+ "formula2",
2061
+ "definedName",
2062
+ "calculatedColumnFormula",
2063
+ "totalsRowFormula",
2064
+ ]);
2065
+ const STYLE_RECORD_ELEMENTS = new Set([
2066
+ "numFmt",
2067
+ "font",
2068
+ "fill",
2069
+ "border",
2070
+ "xf",
2071
+ "cellStyle",
2072
+ "dxf",
2073
+ ]);
2074
+ const SUPPORTED_CONDITIONAL_FORMAT_RULE_TYPES = new Set([
2075
+ "cellis",
2076
+ "expression",
2077
+ "colorscale",
2078
+ "databar",
2079
+ "iconset",
2080
+ "containstext",
2081
+ "notcontainstext",
2082
+ "beginswith",
2083
+ "endswith",
2084
+ "containsblanks",
2085
+ "notcontainsblanks",
2086
+ "containserrors",
2087
+ "notcontainserrors",
2088
+ "duplicatevalues",
2089
+ "uniquevalues",
2090
+ "timeperiod",
2091
+ "top10",
2092
+ "aboveaverage",
2093
+ ]);
2094
+
2095
+ function inspectXml(
2096
+ xml: string,
2097
+ entryName: string,
2098
+ limits: SpreadsheetXlsxImportLimits,
2099
+ entryNames: ReadonlyMap<string, string>,
2100
+ ): XmlInspection {
2101
+ const result: XmlInspection = {
2102
+ attributes: 0,
2103
+ elements: 0,
2104
+ textCharacters: 0,
2105
+ worksheetCells: 0,
2106
+ formulas: 0,
2107
+ formulaBytes: 0,
2108
+ sharedStrings: 0,
2109
+ cellStyles: 0,
2110
+ styleRecords: 0,
2111
+ workbookSheets: [],
2112
+ comments: [],
2113
+ relationships: [],
2114
+ contentTypes: [],
2115
+ opaqueFeatures: [],
2116
+ };
2117
+ const features = new Set<string>();
2118
+ const relationshipIds = new Set<string>();
2119
+ const relationshipTags: Array<ReadonlyMap<string, string>> = [];
2120
+ const stack: string[] = [];
2121
+ let activeFormula: { name: string; text: string } | null = null;
2122
+ let activeMetadata: { name: string; text: string } | null = null;
2123
+ let activeComment: LegacyCommentRecord | null = null;
2124
+ let failed: unknown;
2125
+ const parser = new SaxesParser<{ xmlns: true }>({ xmlns: true });
2126
+
2127
+ const chargeText = (text: string): void => {
2128
+ result.textCharacters += text.length;
2129
+ if (result.textCharacters > limits.xmlTextCharactersPerPart) {
2130
+ throw securityError(
2131
+ `OOXML part exceeds the ${limits.xmlTextCharactersPerPart} text-character limit`,
2132
+ "limit-exceeded",
2133
+ entryName,
2134
+ );
2135
+ }
2136
+ if (activeFormula) {
2137
+ activeFormula.text += text;
2138
+ if (boundedUtf8ByteLength(activeFormula.text, 8_192) > 8_192) {
2139
+ throw securityError(
2140
+ "Spreadsheet formula exceeds its byte limit",
2141
+ "limit-exceeded",
2142
+ entryName,
2143
+ );
2144
+ }
2145
+ }
2146
+ if (activeMetadata) activeMetadata.text += text;
2147
+ if (activeComment && stack.at(-1) === "t") activeComment.text += text;
2148
+ };
2149
+
2150
+ parser.on("doctype", () => {
2151
+ throw securityError(
2152
+ "DTD and entity declarations are forbidden in OOXML",
2153
+ "unsafe-xml",
2154
+ entryName,
2155
+ );
2156
+ });
2157
+ parser.on("processinginstruction", () => {
2158
+ throw securityError("OOXML processing instructions are unsupported", "unsafe-xml", entryName);
2159
+ });
2160
+ parser.on("comment", chargeText);
2161
+ parser.on("text", chargeText);
2162
+ parser.on("cdata", chargeText);
2163
+ parser.on("opentag", (tag: SaxesTagNS) => {
2164
+ const localName = tag.local;
2165
+ if (activeFormula) {
2166
+ throw securityError(
2167
+ "Spreadsheet formula elements cannot contain child markup",
2168
+ "unsafe-xml",
2169
+ entryName,
2170
+ );
2171
+ }
2172
+ result.elements += 1;
2173
+ if (result.elements > limits.xmlElementsPerPart) {
2174
+ throw securityError(
2175
+ `OOXML part exceeds the ${limits.xmlElementsPerPart} element limit`,
2176
+ "limit-exceeded",
2177
+ entryName,
2178
+ );
2179
+ }
2180
+ const elementAttributes = Object.keys(tag.attributes).length;
2181
+ if (elementAttributes > limits.xmlAttributesPerElement) {
2182
+ throw securityError(
2183
+ `OOXML element exceeds the ${limits.xmlAttributesPerElement} attribute limit`,
2184
+ "limit-exceeded",
2185
+ entryName,
2186
+ );
2187
+ }
2188
+ result.attributes += elementAttributes;
2189
+ if (result.attributes > limits.xmlAttributesPerPart) {
2190
+ throw securityError(
2191
+ `OOXML part exceeds the ${limits.xmlAttributesPerPart} attribute limit`,
2192
+ "limit-exceeded",
2193
+ entryName,
2194
+ );
2195
+ }
2196
+ stack.push(localName);
2197
+ if (stack.length > limits.xmlDepth) {
2198
+ throw securityError(
2199
+ `OOXML nesting exceeds the ${limits.xmlDepth} level limit`,
2200
+ "limit-exceeded",
2201
+ entryName,
2202
+ );
2203
+ }
2204
+ const attributes = localAttributes(tag, entryName);
2205
+ rejectActiveXmlElement(entryName, localName, attributes);
2206
+ detectOpaqueXmlFeature(entryName, localName, attributes, stack, features);
2207
+
2208
+ if (/^xl\/workbook\.xml$/i.test(entryName) && localName === "sheet") {
2209
+ const name = attributes.get("name");
2210
+ const relationshipId = attributes.get("id");
2211
+ if (!name || name.length > 31 || !relationshipId || relationshipId.length > 512) {
2212
+ throw securityError(
2213
+ "Workbook sheet binding is missing or invalid",
2214
+ "invalid-package",
2215
+ entryName,
2216
+ );
2217
+ }
2218
+ result.workbookSheets.push({ name, relationshipId });
2219
+ }
2220
+ if (/^xl\/comments[^/]*\.xml$/i.test(entryName) && localName === "comment") {
2221
+ if (activeComment) {
2222
+ throw securityError("Spreadsheet comments cannot be nested", "unsafe-xml", entryName);
2223
+ }
2224
+ if (result.comments.length >= limits.commentsPerPart) {
2225
+ throw securityError(
2226
+ `OOXML part exceeds the ${limits.commentsPerPart} comment limit`,
2227
+ "limit-exceeded",
2228
+ entryName,
2229
+ );
2230
+ }
2231
+ const ref = attributes.get("ref");
2232
+ let range: RangeAddress;
2233
+ try {
2234
+ if (!ref) throw new Error("missing comment reference");
2235
+ range = parseRangeAddress(ref);
2236
+ } catch (error) {
2237
+ const failure = securityError(
2238
+ "Spreadsheet comment reference is invalid",
2239
+ "invalid-package",
2240
+ entryName,
2241
+ );
2242
+ Object.defineProperty(failure, "cause", {
2243
+ value: error,
2244
+ enumerable: false,
2245
+ });
2246
+ throw failure;
2247
+ }
2248
+ if (range.rowCount !== 1 || range.colCount !== 1) {
2249
+ throw securityError(
2250
+ "Spreadsheet comment must reference one cell",
2251
+ "invalid-package",
2252
+ entryName,
2253
+ );
2254
+ }
2255
+ activeComment = { cell: formatCellAddress(range), text: "" };
2256
+ }
2257
+
2258
+ if (/^xl\/worksheets\/[^/]+\.xml$/i.test(entryName) && localName === "c") {
2259
+ result.worksheetCells += 1;
2260
+ if (result.worksheetCells > limits.worksheetCellsPerPart) {
2261
+ throw securityError(
2262
+ `Worksheet exceeds the ${limits.worksheetCellsPerPart} cell limit`,
2263
+ "limit-exceeded",
2264
+ entryName,
2265
+ );
2266
+ }
2267
+ }
2268
+ if (/^xl\/sharedStrings\.xml$/i.test(entryName) && localName === "si") {
2269
+ result.sharedStrings += 1;
2270
+ if (result.sharedStrings > limits.sharedStrings) {
2271
+ throw securityError(
2272
+ `Shared strings exceed the ${limits.sharedStrings} item limit`,
2273
+ "limit-exceeded",
2274
+ entryName,
2275
+ );
2276
+ }
2277
+ }
2278
+ if (/^xl\/styles\.xml$/i.test(entryName) && STYLE_RECORD_ELEMENTS.has(localName)) {
2279
+ result.styleRecords += 1;
2280
+ if (localName === "xf") result.cellStyles += 1;
2281
+ if (result.cellStyles > limits.cellStyles || result.styleRecords > limits.styleRecords) {
2282
+ throw securityError(
2283
+ "Spreadsheet styles exceed their record limit",
2284
+ "limit-exceeded",
2285
+ entryName,
2286
+ );
2287
+ }
2288
+ }
2289
+ if (FORMULA_ELEMENTS.has(localName)) {
2290
+ result.formulas += 1;
2291
+ if (result.formulas > limits.formulasPerPart) {
2292
+ throw securityError(
2293
+ `OOXML part exceeds the ${limits.formulasPerPart} formula limit`,
2294
+ "limit-exceeded",
2295
+ entryName,
2296
+ );
2297
+ }
2298
+ activeFormula = { name: localName, text: "" };
2299
+ }
2300
+ if (
2301
+ /^docProps\/(?:core|app)\.xml$/i.test(entryName) &&
2302
+ new Set([
2303
+ "creator",
2304
+ "title",
2305
+ "subject",
2306
+ "description",
2307
+ "keywords",
2308
+ "category",
2309
+ "lastModifiedBy",
2310
+ "Company",
2311
+ "Manager",
2312
+ ]).has(localName)
2313
+ ) {
2314
+ activeMetadata = { name: localName, text: "" };
2315
+ }
2316
+ if (/\.rels$/i.test(entryName) && localName === "Relationship") {
2317
+ if (relationshipTags.length >= limits.relationshipsPerPart) {
2318
+ throw securityError(
2319
+ `OOXML part exceeds the ${limits.relationshipsPerPart} relationship limit`,
2320
+ "limit-exceeded",
2321
+ entryName,
2322
+ );
2323
+ }
2324
+ const id = attributes.get("Id");
2325
+ if (!id || relationshipIds.has(id)) {
2326
+ throw securityError(
2327
+ "OOXML relationship id is missing or duplicated",
2328
+ "invalid-package",
2329
+ entryName,
2330
+ );
2331
+ }
2332
+ relationshipIds.add(id);
2333
+ relationshipTags.push(attributes);
2334
+ }
2335
+ if (
2336
+ entryName === "[Content_Types].xml" &&
2337
+ (localName === "Default" || localName === "Override")
2338
+ ) {
2339
+ result.contentTypes.push(inspectContentTypeTag(localName, attributes, entryName));
2340
+ }
2341
+ });
2342
+ parser.on("closetag", (tag: SaxesTagNS) => {
2343
+ if (activeFormula && activeFormula.name === tag.local) {
2344
+ const formula = activeFormula.text.trim();
2345
+ if (formula.length > 0) {
2346
+ const formulaBytes = boundedUtf8ByteLength(formula, 8_192);
2347
+ result.formulaBytes += formulaBytes;
2348
+ if (result.formulaBytes > limits.formulaBytesPerPart) {
2349
+ throw securityError(
2350
+ `OOXML part exceeds the ${limits.formulaBytesPerPart} formula-byte limit`,
2351
+ "limit-exceeded",
2352
+ entryName,
2353
+ );
2354
+ }
2355
+ assertSafeFormula(formula, entryName);
2356
+ }
2357
+ activeFormula = null;
2358
+ }
2359
+ if (activeMetadata && activeMetadata.name === tag.local) {
2360
+ const value = activeMetadata.text.trim();
2361
+ if (
2362
+ value.length > 0 &&
2363
+ !(["creator", "lastModifiedBy"].includes(activeMetadata.name) && value === "OpenGeni")
2364
+ ) {
2365
+ features.add("workbook-properties");
2366
+ }
2367
+ activeMetadata = null;
2368
+ }
2369
+ if (activeComment && tag.local === "comment") {
2370
+ result.comments.push(activeComment);
2371
+ activeComment = null;
2372
+ }
2373
+ stack.pop();
2374
+ });
2375
+ parser.on("error", (error: Error) => {
2376
+ failed = securityError(`OOXML is malformed (${error.message})`, "unsafe-xml", entryName);
2377
+ throw failed;
2378
+ });
2379
+ try {
2380
+ parser.write(xml).close();
2381
+ } catch (error) {
2382
+ if (error instanceof SpreadsheetSecurityError) throw error;
2383
+ throw securityError(
2384
+ `OOXML is malformed (${error instanceof Error ? error.message : "parse failure"})`,
2385
+ "unsafe-xml",
2386
+ entryName,
2387
+ );
2388
+ }
2389
+ if (failed) throw failed;
2390
+ if (stack.length !== 0 || activeFormula || activeComment) {
2391
+ throw securityError("OOXML tags are structurally unbalanced", "unsafe-xml", entryName);
2392
+ }
2393
+ result.relationships = relationshipTags.map((attributes) =>
2394
+ inspectRelationshipTag(attributes, entryName, entryNames),
2395
+ );
2396
+ result.opaqueFeatures = [...features].sort();
2397
+ return result;
2398
+ }
2399
+
2400
+ function localAttributes(tag: SaxesTagNS, entryName: string): Map<string, string> {
2401
+ const attributes = new Map<string, string>();
2402
+ for (const attribute of Object.values(tag.attributes)) {
2403
+ const name = attribute.local;
2404
+ if (attributes.has(name)) {
2405
+ throw securityError("OOXML element has duplicate local attributes", "unsafe-xml", entryName);
2406
+ }
2407
+ attributes.set(name, attribute.value);
2408
+ }
2409
+ return attributes;
2410
+ }
2411
+
2412
+ function inspectContentTypeTag(
2413
+ localName: "Default" | "Override",
2414
+ attributes: ReadonlyMap<string, string>,
2415
+ entryName: string,
2416
+ ): ContentTypeDeclaration {
2417
+ const expected =
2418
+ localName === "Default"
2419
+ ? new Set(["Extension", "ContentType"])
2420
+ : new Set(["PartName", "ContentType"]);
2421
+ if ([...attributes.keys()].some((name) => !expected.has(name))) {
2422
+ throw securityError("OOXML content type has unsupported attributes", "unsafe-xml", entryName);
2423
+ }
2424
+ const contentType = attributes.get("ContentType");
2425
+ if (!contentType || contentType.length > 2_048 || !/^[\x21-\x7e]+$/.test(contentType)) {
2426
+ throw securityError("OOXML content type is missing or invalid", "unsafe-xml", entryName);
2427
+ }
2428
+ rejectActiveContentType(contentType, entryName);
2429
+ if (localName === "Default") {
2430
+ const extension = attributes.get("Extension");
2431
+ if (!extension || extension.length > 32 || !/^[A-Za-z0-9]+$/.test(extension)) {
2432
+ throw securityError("OOXML default extension is invalid", "unsafe-xml", entryName);
2433
+ }
2434
+ return { kind: "default", extension: extension.toLowerCase(), contentType };
2435
+ }
2436
+ const rawPartName = attributes.get("PartName");
2437
+ if (!rawPartName || !rawPartName.startsWith("/") || rawPartName.length > 2_049) {
2438
+ throw securityError("OOXML override part name is invalid", "unsafe-xml", entryName);
2439
+ }
2440
+ const partName = rawPartName.slice(1);
2441
+ if (!isCanonicalPartName(partName)) {
2442
+ throw securityError("OOXML override part name is non-canonical", "invalid-package", entryName);
2443
+ }
2444
+ return { kind: "override", partName, contentType };
2445
+ }
2446
+
2447
+ function rejectActiveContentType(contentType: string, entryName: string): void {
2448
+ if (
2449
+ /(?:macroenabled|vba|ole|activex|external(?:link|data)|connections?|querytable|webextension|html|javascript|svg|executable|encrypted|signature)/i.test(
2450
+ contentType,
2451
+ )
2452
+ ) {
2453
+ throw securityError("Active OOXML content types are unsupported", "active-content", entryName);
2454
+ }
2455
+ }
2456
+
2457
+ function rejectActiveXmlElement(
2458
+ entryName: string,
2459
+ localName: string,
2460
+ attributes: ReadonlyMap<string, string>,
2461
+ ): void {
2462
+ if (
2463
+ new Set([
2464
+ "oleObject",
2465
+ "oleObjects",
2466
+ "controls",
2467
+ "control",
2468
+ "activeX",
2469
+ "externalLink",
2470
+ "externalData",
2471
+ "connection",
2472
+ "connections",
2473
+ "queryTable",
2474
+ "webExtension",
2475
+ ]).has(localName)
2476
+ ) {
2477
+ throw securityError(
2478
+ "Active or externally connected OOXML is unsupported",
2479
+ "active-content",
2480
+ entryName,
2481
+ );
2482
+ }
2483
+ if (/\.vml$/i.test(entryName)) {
2484
+ if (["imagedata", "OLEObject"].includes(localName)) {
2485
+ throw securityError(
2486
+ "Active or linked VML content is unsupported",
2487
+ "active-content",
2488
+ entryName,
2489
+ );
2490
+ }
2491
+ for (const name of ["href", "src"]) {
2492
+ if (attributes.has(name)) {
2493
+ throw securityError(
2494
+ "Active or linked VML content is unsupported",
2495
+ "active-content",
2496
+ entryName,
2497
+ );
2498
+ }
2499
+ }
2500
+ }
2501
+ }
2502
+
2503
+ function detectOpaqueXmlFeature(
2504
+ entryName: string,
2505
+ localName: string,
2506
+ attributes: ReadonlyMap<string, string>,
2507
+ stack: readonly string[],
2508
+ features: Set<string>,
2509
+ ): void {
2510
+ if (/^xl\/charts\//i.test(entryName)) features.add("editable-charts");
2511
+ if (/^customXml\//i.test(entryName)) features.add("custom-xml");
2512
+ if (/^xl\/comments[^/]*\.xml$/i.test(entryName)) features.add("comment-metadata");
2513
+ if (/\.vml$/i.test(entryName)) features.add("comment-layout");
2514
+
2515
+ if (/^xl\/workbook\.xml$/i.test(entryName)) {
2516
+ if (localName === "sheet" && (attributes.get("state") ?? "visible") !== "visible") {
2517
+ features.add("sheet-visibility");
2518
+ }
2519
+ if (localName === "definedName" || localName === "definedNames") {
2520
+ features.add("defined-names");
2521
+ }
2522
+ if (localName === "workbookPr" && isTruthyXml(attributes.get("date1904"))) {
2523
+ features.add("1904-date-system");
2524
+ }
2525
+ if (
2526
+ localName === "workbookView" &&
2527
+ (positiveXmlNumber(attributes.get("activeTab")) ||
2528
+ positiveXmlNumber(attributes.get("firstSheet")))
2529
+ ) {
2530
+ features.add("workbook-view-state");
2531
+ }
2532
+ }
2533
+
2534
+ if (/^xl\/theme\/[^/]+\.xml$/i.test(entryName)) {
2535
+ if (
2536
+ (localName === "theme" && attributes.get("name") !== "Office Theme") ||
2537
+ (localName === "clrScheme" && attributes.get("name") !== "Office")
2538
+ ) {
2539
+ features.add("workbook-theme");
2540
+ }
2541
+ const colorRole = stack.at(-2);
2542
+ const expectedThemeColors: Readonly<Record<string, string>> = {
2543
+ dk1: "000000",
2544
+ lt1: "FFFFFF",
2545
+ dk2: "1F497D",
2546
+ lt2: "EEECE1",
2547
+ accent1: "4F81BD",
2548
+ accent2: "C0504D",
2549
+ accent3: "9BBB59",
2550
+ accent4: "8064A2",
2551
+ accent5: "4BACC6",
2552
+ accent6: "F79646",
2553
+ hlink: "0000FF",
2554
+ folHlink: "800080",
2555
+ };
2556
+ if ((localName === "sysClr" || localName === "srgbClr") && colorRole) {
2557
+ const expected = expectedThemeColors[colorRole];
2558
+ const actual = (attributes.get("lastClr") ?? attributes.get("val"))?.toUpperCase();
2559
+ if (expected !== undefined && actual !== expected) features.add("workbook-theme");
2560
+ }
2561
+ if (localName === "latin") {
2562
+ const fontRole = stack.at(-2);
2563
+ const expected =
2564
+ fontRole === "majorFont" ? "Cambria" : fontRole === "minorFont" ? "Calibri" : undefined;
2565
+ if (expected !== undefined && attributes.get("typeface") !== expected) {
2566
+ features.add("workbook-theme");
2567
+ }
2568
+ }
2569
+ }
2570
+
2571
+ if (/^xl\/worksheets\/[^/]+\.xml$/i.test(entryName)) {
2572
+ if (
2573
+ new Set([
2574
+ "hyperlinks",
2575
+ "hyperlink",
2576
+ "sheetProtection",
2577
+ "protectedRanges",
2578
+ "autoFilter",
2579
+ "sortState",
2580
+ "printOptions",
2581
+ "headerFooter",
2582
+ "rowBreaks",
2583
+ "colBreaks",
2584
+ "selection",
2585
+ ]).has(localName)
2586
+ ) {
2587
+ features.add(`worksheet-${localName}`);
2588
+ }
2589
+ if (
2590
+ localName === "pageMargins" &&
2591
+ !xmlAttributesEqual(attributes, {
2592
+ left: "0.7",
2593
+ right: "0.7",
2594
+ top: "0.75",
2595
+ bottom: "0.75",
2596
+ header: "0.3",
2597
+ footer: "0.3",
2598
+ })
2599
+ ) {
2600
+ features.add("worksheet-pageMargins");
2601
+ }
2602
+ if (
2603
+ localName === "pageSetup" &&
2604
+ !xmlAttributesEqual(attributes, {
2605
+ orientation: "portrait",
2606
+ horizontalDpi: "4294967295",
2607
+ verticalDpi: "4294967295",
2608
+ scale: "100",
2609
+ fitToWidth: "1",
2610
+ fitToHeight: "1",
2611
+ })
2612
+ ) {
2613
+ features.add("worksheet-pageSetup");
2614
+ }
2615
+ if (
2616
+ localName === "row" &&
2617
+ ["hidden", "outlineLevel", "collapsed", "s", "customFormat"].some((name) =>
2618
+ attributes.has(name),
2619
+ )
2620
+ ) {
2621
+ features.add("row-metadata");
2622
+ }
2623
+ if (
2624
+ localName === "col" &&
2625
+ ["hidden", "outlineLevel", "collapsed", "style", "bestFit"].some((name) =>
2626
+ attributes.has(name),
2627
+ )
2628
+ ) {
2629
+ features.add("column-metadata");
2630
+ }
2631
+ if (localName === "sheetView") {
2632
+ const supported = new Set(["workbookViewId", "showGridLines"]);
2633
+ if ([...attributes.keys()].some((name) => !supported.has(name))) {
2634
+ features.add("worksheet-view-state");
2635
+ }
2636
+ }
2637
+ if (localName === "pane") {
2638
+ const supported = new Set(["xSplit", "ySplit", "state"]);
2639
+ if (
2640
+ (attributes.get("state") ?? "frozen") !== "frozen" ||
2641
+ [...attributes.keys()].some((name) => !supported.has(name))
2642
+ ) {
2643
+ features.add("worksheet-pane-state");
2644
+ }
2645
+ }
2646
+ if (localName === "f" && attributes.size > 0) features.add("special-formulas");
2647
+ if (localName === "extLst") features.add("worksheet-extensions");
2648
+ if (localName === "sparklineGroups" || localName === "sparklineGroup") {
2649
+ features.add("sparklines");
2650
+ }
2651
+ if (localName === "r" && stack.includes("is")) features.add("rich-text");
2652
+ if (localName === "cfRule") {
2653
+ const type = attributes.get("type");
2654
+ if (type && !SUPPORTED_CONDITIONAL_FORMAT_RULE_TYPES.has(type.toLowerCase())) {
2655
+ features.add("conditional-format-rules");
2656
+ }
2657
+ }
2658
+ }
2659
+ if (/^xl\/sharedStrings\.xml$/i.test(entryName) && localName === "r") {
2660
+ features.add("rich-text");
2661
+ }
2662
+ if (/^xl\/styles\.xml$/i.test(entryName)) {
2663
+ if (
2664
+ [
2665
+ "gradientFill",
2666
+ "protection",
2667
+ "vertical",
2668
+ "horizontal",
2669
+ "strike",
2670
+ "outline",
2671
+ "shadow",
2672
+ "condense",
2673
+ "extend",
2674
+ "vertAlign",
2675
+ ].includes(localName)
2676
+ ) {
2677
+ features.add("advanced-cell-styles");
2678
+ }
2679
+ if (
2680
+ [...attributes].some(
2681
+ ([name, value]) =>
2682
+ [
2683
+ "indexed",
2684
+ "auto",
2685
+ "textRotation",
2686
+ "indent",
2687
+ "shrinkToFit",
2688
+ "readingOrder",
2689
+ "justifyLastLine",
2690
+ "relativeIndent",
2691
+ "diagonalUp",
2692
+ "diagonalDown",
2693
+ ].includes(name) ||
2694
+ (name === "theme" && value !== "1"),
2695
+ )
2696
+ ) {
2697
+ features.add("advanced-cell-styles");
2698
+ }
2699
+ if (
2700
+ localName === "patternFill" &&
2701
+ ![undefined, "none", "gray125", "solid"].includes(attributes.get("patternType"))
2702
+ ) {
2703
+ features.add("advanced-cell-styles");
2704
+ }
2705
+ }
2706
+ if (/^xl\/tables\/[^/]+\.xml$/i.test(entryName)) {
2707
+ if (
2708
+ ["calculatedColumnFormula", "totalsRowFormula", "sortState", "filterColumn"].includes(
2709
+ localName,
2710
+ )
2711
+ ) {
2712
+ features.add("advanced-table-semantics");
2713
+ }
2714
+ if (
2715
+ localName === "tableStyleInfo" &&
2716
+ (isTruthyXml(attributes.get("showFirstColumn")) ||
2717
+ isTruthyXml(attributes.get("showLastColumn")) ||
2718
+ isTruthyXml(attributes.get("showRowStripes")) ===
2719
+ isTruthyXml(attributes.get("showColumnStripes")))
2720
+ ) {
2721
+ features.add("advanced-table-style");
2722
+ }
2723
+ }
2724
+ if (/^xl\/drawings\/[^/]+\.xml$/i.test(entryName)) {
2725
+ if (
2726
+ ["sp", "grpSp", "cxnSp", "graphicFrame", "absoluteAnchor", "twoCellAnchor"].includes(
2727
+ localName,
2728
+ )
2729
+ ) {
2730
+ features.add("drawing-objects");
2731
+ }
2732
+ if (localName === "cNvPr" && (attributes.has("descr") || attributes.has("title"))) {
2733
+ features.add("image-metadata");
2734
+ }
2735
+ }
2736
+ }
2737
+
2738
+ function isTruthyXml(value: string | undefined): boolean {
2739
+ return value === "1" || value?.toLowerCase() === "true";
2740
+ }
2741
+
2742
+ function positiveXmlNumber(value: string | undefined): boolean {
2743
+ if (value === undefined) return false;
2744
+ const number = Number(value);
2745
+ return Number.isFinite(number) && number > 0;
2746
+ }
2747
+
2748
+ function xmlAttributesEqual(
2749
+ actual: ReadonlyMap<string, string>,
2750
+ expected: Readonly<Record<string, string>>,
2751
+ ): boolean {
2752
+ const entries = Object.entries(expected);
2753
+ return (
2754
+ actual.size === entries.length && entries.every(([name, value]) => actual.get(name) === value)
2755
+ );
2756
+ }
2757
+
2758
+ type RelationshipPolicy = {
2759
+ source: RegExp;
2760
+ kind: string;
2761
+ target: RegExp;
2762
+ opaque?: boolean;
2763
+ };
2764
+
2765
+ const RELATIONSHIP_POLICIES: readonly RelationshipPolicy[] = [
2766
+ { source: /^$/, kind: "officeDocument", target: /^xl\/workbook\.xml$/i },
2767
+ { source: /^$/, kind: "core-properties", target: /^docProps\/core\.xml$/i },
2768
+ {
2769
+ source: /^$/,
2770
+ kind: "extended-properties",
2771
+ target: /^docProps\/app\.xml$/i,
2772
+ },
2773
+ {
2774
+ source: /^$/,
2775
+ kind: "custom-properties",
2776
+ target: /^docProps\/custom\.xml$/i,
2777
+ opaque: true,
2778
+ },
2779
+ {
2780
+ source: /^xl\/workbook\.xml$/i,
2781
+ kind: "styles",
2782
+ target: /^xl\/styles\.xml$/i,
2783
+ },
2784
+ {
2785
+ source: /^xl\/workbook\.xml$/i,
2786
+ kind: "theme",
2787
+ target: /^xl\/theme\/[^/]+\.xml$/i,
2788
+ },
2789
+ {
2790
+ source: /^xl\/workbook\.xml$/i,
2791
+ kind: "sharedStrings",
2792
+ target: /^xl\/sharedStrings\.xml$/i,
2793
+ },
2794
+ {
2795
+ source: /^xl\/workbook\.xml$/i,
2796
+ kind: "worksheet",
2797
+ target: /^xl\/worksheets\/[^/]+\.xml$/i,
2798
+ },
2799
+ {
2800
+ source: /^xl\/workbook\.xml$/i,
2801
+ kind: "calcChain",
2802
+ target: /^xl\/calcChain\.xml$/i,
2803
+ },
2804
+ {
2805
+ source: /^xl\/worksheets\/[^/]+\.xml$/i,
2806
+ kind: "comments",
2807
+ target: /^xl\/comments[^/]*\.xml$/i,
2808
+ opaque: true,
2809
+ },
2810
+ {
2811
+ source: /^xl\/worksheets\/[^/]+\.xml$/i,
2812
+ kind: "vmlDrawing",
2813
+ target: /^xl\/drawings\/[^/]+\.vml$/i,
2814
+ opaque: true,
2815
+ },
2816
+ {
2817
+ source: /^xl\/worksheets\/[^/]+\.xml$/i,
2818
+ kind: "table",
2819
+ target: /^xl\/tables\/[^/]+\.xml$/i,
2820
+ },
2821
+ {
2822
+ source: /^xl\/worksheets\/[^/]+\.xml$/i,
2823
+ kind: "drawing",
2824
+ target: /^xl\/drawings\/[^/]+\.xml$/i,
2825
+ },
2826
+ {
2827
+ source: /^xl\/drawings\/[^/]+\.xml$/i,
2828
+ kind: "image",
2829
+ target: /^xl\/media\/[^/]+\.(?:png|jpe?g|gif)$/i,
2830
+ },
2831
+ {
2832
+ source: /^xl\/drawings\/[^/]+\.xml$/i,
2833
+ kind: "chart",
2834
+ target: /^xl\/charts\/[^/]+\.xml$/i,
2835
+ opaque: true,
2836
+ },
2837
+ {
2838
+ source: /^xl\/charts\/[^/]+\.xml$/i,
2839
+ kind: "image",
2840
+ target: /^xl\/media\/[^/]+\.(?:png|jpe?g|gif)$/i,
2841
+ opaque: true,
2842
+ },
2843
+ {
2844
+ source: /^xl\/charts\/[^/]+\.xml$/i,
2845
+ kind: "themeOverride",
2846
+ target: /^xl\/theme\/[^/]+\.xml$/i,
2847
+ opaque: true,
2848
+ },
2849
+ {
2850
+ source: /^xl\/workbook\.xml$/i,
2851
+ kind: "customXml",
2852
+ target: /^customXml\/item\d+\.xml$/i,
2853
+ opaque: true,
2854
+ },
2855
+ {
2856
+ source: /^customXml\/item\d+\.xml$/i,
2857
+ kind: "customXmlProps",
2858
+ target: /^customXml\/itemProps\d+\.xml$/i,
2859
+ opaque: true,
2860
+ },
2861
+ ];
2862
+
2863
+ const OFFICE_RELATIONSHIP_PREFIXES = [
2864
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/",
2865
+ "http://purl.oclc.org/ooxml/officeDocument/relationships/",
2866
+ "http://schemas.microsoft.com/office/2006/relationships/",
2867
+ ] as const;
2868
+
2869
+ function inspectRelationshipTag(
2870
+ attributes: ReadonlyMap<string, string>,
2871
+ entryName: string,
2872
+ entryNames: ReadonlyMap<string, string>,
2873
+ ): InspectedRelationship {
2874
+ const source = relationshipSource(entryName);
2875
+ for (const name of attributes.keys()) {
2876
+ if (!["Id", "Type", "Target", "TargetMode"].includes(name)) {
2877
+ throw securityError("OOXML relationship attributes are unsupported", "unsafe-xml", entryName);
2878
+ }
2879
+ }
2880
+ const type = attributes.get("Type");
2881
+ const target = attributes.get("Target");
2882
+ const id = attributes.get("Id");
2883
+ if (!id || id.length > 512 || !type || type.length > 2_048 || !target || target.length > 4_096) {
2884
+ throw securityError(
2885
+ "OOXML relationship is missing or exceeds required attributes",
2886
+ "unsafe-xml",
2887
+ entryName,
2888
+ );
2889
+ }
2890
+ if ((attributes.get("TargetMode") ?? "Internal") !== "Internal") {
2891
+ throw securityError(
2892
+ "External OOXML relationships are forbidden",
2893
+ "external-relationship",
2894
+ entryName,
2895
+ );
2896
+ }
2897
+ const kind = relationshipKind(type);
2898
+ const resolvedTarget = resolveRelationshipTarget(source, target, entryName);
2899
+ if (source !== "" && !entryNames.has(source.toLowerCase())) {
2900
+ throw securityError("OOXML relationship source part is missing", "invalid-package", entryName);
2901
+ }
2902
+ const policy = RELATIONSHIP_POLICIES.find(
2903
+ (candidate) =>
2904
+ candidate.kind === kind &&
2905
+ candidate.source.test(source) &&
2906
+ candidate.target.test(resolvedTarget),
2907
+ );
2908
+ if (!policy) {
2909
+ throw securityError(
2910
+ "Unsupported OOXML relationship type or target",
2911
+ "active-content",
2912
+ entryName,
2913
+ );
2914
+ }
2915
+ if (!entryNames.has(resolvedTarget.toLowerCase())) {
2916
+ throw securityError(
2917
+ "OOXML relationship target is missing from the package",
2918
+ "invalid-package",
2919
+ entryName,
2920
+ );
2921
+ }
2922
+ return {
2923
+ id,
2924
+ kind: kind!,
2925
+ sourcePart: source,
2926
+ type,
2927
+ targetPart: entryNames.get(resolvedTarget.toLowerCase())!,
2928
+ opaque: policy.opaque === true,
2929
+ };
2930
+ }
2931
+
2932
+ function relationshipKind(type: string): string | null {
2933
+ if (
2934
+ type === "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"
2935
+ ) {
2936
+ return "core-properties";
2937
+ }
2938
+ for (const prefix of OFFICE_RELATIONSHIP_PREFIXES) {
2939
+ if (type.startsWith(prefix)) {
2940
+ const kind = type.slice(prefix.length);
2941
+ return /^[A-Za-z][A-Za-z0-9.-]*$/.test(kind) ? kind : null;
2942
+ }
2943
+ }
2944
+ return null;
2945
+ }
2946
+
2947
+ function relationshipSource(entryName: string): string {
2948
+ if (entryName.toLowerCase() === "_rels/.rels") return "";
2949
+ const match = /^(.*\/)_rels\/([^/]+)\.rels$/i.exec(entryName);
2950
+ if (!match) {
2951
+ throw securityError(
2952
+ "OOXML relationship part has an invalid location",
2953
+ "invalid-package",
2954
+ entryName,
2955
+ );
2956
+ }
2957
+ return `${match[1]}${match[2]}`;
2958
+ }
2959
+
2960
+ function resolveRelationshipTarget(source: string, target: string, entryName: string): string {
2961
+ if (
2962
+ target.length === 0 ||
2963
+ target.includes("\\") ||
2964
+ target.includes("%") ||
2965
+ target.includes("?") ||
2966
+ target.includes("#") ||
2967
+ target.startsWith("/") ||
2968
+ /^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) ||
2969
+ /[\u0000-\u001f\u007f]/.test(target)
2970
+ ) {
2971
+ throw securityError(
2972
+ "External or ambiguous OOXML relationship target",
2973
+ "external-relationship",
2974
+ entryName,
2975
+ );
2976
+ }
2977
+ const segments = source.includes("/") ? source.split("/").slice(0, -1) : [];
2978
+ for (const segment of target.split("/")) {
2979
+ if (segment === "" || segment === ".") continue;
2980
+ if (segment === "..") {
2981
+ if (segments.length === 0) {
2982
+ throw securityError(
2983
+ "OOXML relationship escapes the package root",
2984
+ "external-relationship",
2985
+ entryName,
2986
+ );
2987
+ }
2988
+ segments.pop();
2989
+ } else {
2990
+ segments.push(segment);
2991
+ }
2992
+ }
2993
+ if (segments.length === 0) {
2994
+ throw securityError("OOXML relationship target is empty", "invalid-package", entryName);
2995
+ }
2996
+ return segments.join("/");
2997
+ }
2998
+
2999
+ function securityError(
3000
+ message: string,
3001
+ code: SpreadsheetSecurityError["code"],
3002
+ entryName?: string,
3003
+ ): SpreadsheetSecurityError {
3004
+ return entryName
3005
+ ? new SpreadsheetSecurityError(`${message}: ${entryName}`, code, entryName)
3006
+ : new SpreadsheetSecurityError(message, code);
3007
+ }
3008
+
3009
+ function xlsxZipFailure(
3010
+ kind: Parameters<BoundedZipFailure>[0],
3011
+ message: string,
3012
+ entryName?: string,
3013
+ ): never {
3014
+ throw securityError(
3015
+ message,
3016
+ kind === "limit"
3017
+ ? "limit-exceeded"
3018
+ : kind === "encrypted"
3019
+ ? "encrypted-content"
3020
+ : "invalid-package",
3021
+ entryName,
3022
+ );
3023
+ }
3024
+
3025
+ function assertSafeFormula(formula: string, location: string): void {
3026
+ try {
3027
+ validateSupportedFormula(formula);
3028
+ } catch (error) {
3029
+ const failure = securityError(
3030
+ `Unsupported or potentially active spreadsheet formula at ${location}`,
3031
+ "active-content",
3032
+ );
3033
+ Object.defineProperty(failure, "cause", {
3034
+ value: error,
3035
+ enumerable: false,
3036
+ });
3037
+ throw failure;
3038
+ }
3039
+ }
3040
+
3041
+ function safeExportImage(image: SpreadsheetImageConfig): SpreadsheetImageConfig {
3042
+ let normalized: SpreadsheetImageConfig;
3043
+ try {
3044
+ normalized = normalizeSpreadsheetImageConfig(image);
3045
+ } catch (error) {
3046
+ const failure = securityError("Spreadsheet image data is invalid", "active-content");
3047
+ Object.defineProperty(failure, "cause", {
3048
+ value: error,
3049
+ enumerable: false,
3050
+ });
3051
+ throw failure;
3052
+ }
3053
+ if (
3054
+ !normalized.dataUrl ||
3055
+ !["image/png", "image/jpeg", "image/gif"].includes(normalized.contentType ?? "")
3056
+ ) {
3057
+ throw securityError(
3058
+ "Spreadsheet images must be inline PNG, JPEG, or GIF data",
3059
+ "active-content",
3060
+ );
3061
+ }
3062
+ return normalized;
3063
+ }