@bendyline/squisq-formats 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/{chunk-CX3LH47J.js → chunk-74GO3FVS.js} +3 -3
  2. package/dist/{chunk-4HJ6AK2Q.js → chunk-7ARKUCQT.js} +52 -13
  3. package/dist/chunk-7ARKUCQT.js.map +1 -0
  4. package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
  5. package/dist/chunk-DTDF6QDP.js.map +1 -0
  6. package/dist/{chunk-ZZ7D4DN6.js → chunk-EW54IRRS.js} +39 -9
  7. package/dist/chunk-EW54IRRS.js.map +1 -0
  8. package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
  9. package/dist/chunk-FE6OJV6O.js.map +1 -0
  10. package/dist/{chunk-XTLQXR4U.js → chunk-KURGXM4I.js} +3 -3
  11. package/dist/{chunk-2CSZJEAL.js → chunk-NNHKUXKA.js} +292 -70
  12. package/dist/chunk-NNHKUXKA.js.map +1 -0
  13. package/dist/{chunk-2PRVLAU3.js → chunk-QFLDYKCR.js} +55 -17
  14. package/dist/chunk-QFLDYKCR.js.map +1 -0
  15. package/dist/{chunk-CVNVD7VH.js → chunk-RLU7UFYU.js} +5 -5
  16. package/dist/{chunk-X4USWOQ2.js → chunk-SC67HYQJ.js} +2 -2
  17. package/dist/{chunk-HBSCRS5P.js → chunk-UGYF5AZE.js} +3 -3
  18. package/dist/{chunk-2EFDWKC6.js → chunk-WQSHGBLN.js} +86 -23
  19. package/dist/chunk-WQSHGBLN.js.map +1 -0
  20. package/dist/container/index.d.ts +10 -4
  21. package/dist/container/index.js +9 -4
  22. package/dist/container/index.js.map +1 -1
  23. package/dist/docx/index.d.ts +2 -2
  24. package/dist/docx/index.js +5 -4
  25. package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
  26. package/dist/{extract-LNOA7DBK.js → extract-OJ7ZQV6P.js} +5 -5
  27. package/dist/html/index.js +1 -1
  28. package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
  29. package/dist/index.d.ts +8 -7
  30. package/dist/index.js +15 -13
  31. package/dist/infer/index.d.ts +4 -4
  32. package/dist/infer/index.js +5 -5
  33. package/dist/{layouts-7OUUVAGU.js → layouts-5VDIRPIJ.js} +4 -4
  34. package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
  35. package/dist/ooxml/index.d.ts +4 -4
  36. package/dist/ooxml/index.js +4 -4
  37. package/dist/pptx/index.d.ts +5 -5
  38. package/dist/pptx/index.js +5 -5
  39. package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
  40. package/dist/registry/index.d.ts +20 -6
  41. package/dist/registry/index.js +6 -4
  42. package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
  43. package/dist/xlsx/index.d.ts +4 -4
  44. package/dist/xlsx/index.js +4 -4
  45. package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
  46. package/package.json +2 -2
  47. package/src/__tests__/convert.test.ts +143 -1
  48. package/src/__tests__/docxExport.test.ts +34 -0
  49. package/src/__tests__/docxImport.test.ts +121 -0
  50. package/src/__tests__/html.test.ts +4 -0
  51. package/src/__tests__/ooxml.test.ts +17 -0
  52. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  53. package/src/container/index.ts +17 -3
  54. package/src/docx/export.ts +113 -5
  55. package/src/docx/import.ts +324 -69
  56. package/src/html/htmlTemplate.ts +2 -2
  57. package/src/index.ts +3 -0
  58. package/src/infer/index.ts +35 -4
  59. package/src/ooxml/namespaces.ts +21 -0
  60. package/src/ooxml/reader.ts +55 -14
  61. package/src/pptx/layouts.ts +68 -14
  62. package/src/registry/convert.ts +115 -31
  63. package/src/registry/defaultFormats.ts +5 -2
  64. package/src/registry/index.ts +3 -1
  65. package/src/registry/types.ts +18 -1
  66. package/src/shared/boundedZipArchive.ts +49 -8
  67. package/src/shared/zipEntryCount.ts +59 -5
  68. package/src/shared/zipLimits.ts +10 -0
  69. package/dist/chunk-2CSZJEAL.js.map +0 -1
  70. package/dist/chunk-2EFDWKC6.js.map +0 -1
  71. package/dist/chunk-2PRVLAU3.js.map +0 -1
  72. package/dist/chunk-4HJ6AK2Q.js.map +0 -1
  73. package/dist/chunk-H7T5DXUU.js.map +0 -1
  74. package/dist/chunk-LXYLOOST.js.map +0 -1
  75. package/dist/chunk-ZZ7D4DN6.js.map +0 -1
  76. /package/dist/{chunk-CX3LH47J.js.map → chunk-74GO3FVS.js.map} +0 -0
  77. /package/dist/{chunk-XTLQXR4U.js.map → chunk-KURGXM4I.js.map} +0 -0
  78. /package/dist/{chunk-CVNVD7VH.js.map → chunk-RLU7UFYU.js.map} +0 -0
  79. /package/dist/{chunk-X4USWOQ2.js.map → chunk-SC67HYQJ.js.map} +0 -0
  80. /package/dist/{chunk-HBSCRS5P.js.map → chunk-UGYF5AZE.js.map} +0 -0
  81. /package/dist/{extract-LNOA7DBK.js.map → extract-OJ7ZQV6P.js.map} +0 -0
  82. /package/dist/{layouts-7OUUVAGU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
@@ -22,12 +22,22 @@ import type {
22
22
  FormatId,
23
23
  FormatRegistry,
24
24
  NormalizedInput,
25
+ PreparedConversion,
26
+ PreparedExportOptions,
25
27
  } from './types.js';
26
28
 
27
29
  function markdownFormatOptions(options: ConvertOptions): BuiltinFormatOptions['md'] {
28
30
  return (options.formatOptions?.md ?? {}) as BuiltinFormatOptions['md'];
29
31
  }
30
32
 
33
+ function throwIfAborted(signal?: AbortSignal): void {
34
+ if (!signal?.aborted) return;
35
+ if (signal.reason !== undefined) throw signal.reason;
36
+ const error = new Error('Document conversion was cancelled.');
37
+ error.name = 'AbortError';
38
+ throw error;
39
+ }
40
+
31
41
  // ── Byte sniffing ───────────────────────────────────────────────────
32
42
 
33
43
  function hasPrefix(bytes: Uint8Array, prefix: number[]): boolean {
@@ -274,39 +284,30 @@ async function applyTransformStyle(
274
284
  return [];
275
285
  }
276
286
 
277
- // ── convert() ───────────────────────────────────────────────────────
287
+ // ── prepare / convert ───────────────────────────────────────────────
278
288
 
279
- /**
280
- * Convert a source document to a target format.
281
- *
282
- * @param source - A bytes / markdown / doc source.
283
- * @param to - The target format id (must be registered and support export).
284
- * @param options - Conversion options (registry, theme, transform, …).
285
- * @throws {@link ConversionError} on any failure, with a stable `code`.
286
- */
287
- export async function convert(
289
+ /** Normalize and transform a source once, then safely export that snapshot many times. */
290
+ export async function prepareConversion(
288
291
  source: ConvertSource,
289
- to: FormatId,
290
292
  options: ConvertOptions = {},
291
- ): Promise<ConversionResult> {
292
- const registry = options.registry ?? defaultRegistry();
293
+ ): Promise<PreparedConversion> {
294
+ return prepareConversionInternal(source, options);
295
+ }
293
296
 
294
- const target: FormatDefinition | undefined = registry.get(to);
295
- if (!target) {
296
- throw new ConversionError('unknown-format', `Unknown target format "${to}".`, { format: to });
297
- }
298
- if (!target.exportDoc) {
299
- throw new ConversionError(
300
- 'unsupported-output',
301
- `Format "${target.label}" does not support export.`,
302
- { format: to, hint: 'This format is import-only.' },
303
- );
304
- }
297
+ async function prepareConversionInternal(
298
+ source: ConvertSource,
299
+ options: ConvertOptions,
300
+ failureFormat?: FormatId,
301
+ ): Promise<PreparedConversion> {
302
+ throwIfAborted(options.signal);
303
+ const registry = options.registry ?? defaultRegistry();
305
304
 
306
305
  let normalized: Normalized;
307
306
  try {
308
307
  normalized = await normalize(source, options, registry);
308
+ throwIfAborted(options.signal);
309
309
  } catch (err: unknown) {
310
+ throwIfAborted(options.signal);
310
311
  if (err instanceof ConversionError) throw err;
311
312
  const message = err instanceof Error ? err.message : String(err);
312
313
  throw new ConversionError('invalid-input', message, {
@@ -321,7 +322,6 @@ export async function convert(
321
322
  input.doc = { ...input.doc, themeId: options.themeId };
322
323
  }
323
324
 
324
- let result: ConversionResult;
325
325
  try {
326
326
  if (options.transformStyle) {
327
327
  warnings.push(
@@ -332,18 +332,102 @@ export async function convert(
332
332
  options.transformRegistry,
333
333
  )),
334
334
  );
335
+ throwIfAborted(options.signal);
335
336
  }
336
- result = await target.exportDoc(input, options);
337
337
  } catch (err: unknown) {
338
+ throwIfAborted(options.signal);
338
339
  if (err instanceof ConversionError) throw err;
339
340
  const message = err instanceof Error ? err.message : String(err);
340
- throw new ConversionError('conversion-failed', message, { format: to, cause: err });
341
+ throw new ConversionError('conversion-failed', message, {
342
+ format: failureFormat,
343
+ cause: err,
344
+ });
341
345
  }
342
346
 
343
- const ext = target.extensions[0]?.replace(/^\.+/, '') ?? to;
347
+ const preparedWarnings = Object.freeze([...warnings]);
348
+ const preparedInput = { ...input };
349
+ return Object.freeze({
350
+ async convert(to: FormatId, targetOptions: PreparedExportOptions = {}) {
351
+ const target = requireExportTarget(registry, to);
352
+ const exportOptions = mergePreparedExportOptions(options, targetOptions);
353
+ throwIfAborted(exportOptions.signal);
354
+ let result: ConversionResult;
355
+ try {
356
+ result = await target.exportDoc!({ ...preparedInput }, exportOptions);
357
+ throwIfAborted(exportOptions.signal);
358
+ } catch (err: unknown) {
359
+ throwIfAborted(exportOptions.signal);
360
+ if (err instanceof ConversionError) throw err;
361
+ const message = err instanceof Error ? err.message : String(err);
362
+ throw new ConversionError('conversion-failed', message, { format: to, cause: err });
363
+ }
364
+
365
+ const ext = target.extensions[0]?.replace(/^\.+/, '') ?? to;
366
+ return {
367
+ ...result,
368
+ suggestedFilename: `${preparedInput.baseName}.${ext}`,
369
+ warnings: [...preparedWarnings, ...result.warnings],
370
+ };
371
+ },
372
+ });
373
+ }
374
+
375
+ function requireExportTarget(registry: FormatRegistry, to: FormatId): FormatDefinition {
376
+ const target = registry.get(to);
377
+ if (!target) {
378
+ throw new ConversionError('unknown-format', `Unknown target format "${to}".`, { format: to });
379
+ }
380
+ if (!target.exportDoc) {
381
+ throw new ConversionError(
382
+ 'unsupported-output',
383
+ `Format "${target.label}" does not support export.`,
384
+ { format: to, hint: 'This format is import-only.' },
385
+ );
386
+ }
387
+ return target;
388
+ }
389
+
390
+ function mergePreparedExportOptions(
391
+ prepared: ConvertOptions,
392
+ target: PreparedExportOptions,
393
+ ): ConvertOptions {
344
394
  return {
345
- ...result,
346
- suggestedFilename: `${input.baseName}.${ext}`,
347
- warnings: [...warnings, ...result.warnings],
395
+ ...prepared,
396
+ signal: mergeAbortSignals(prepared.signal, target.signal),
397
+ title: target.title ?? prepared.title,
398
+ resolvePlayerScript: target.resolvePlayerScript ?? prepared.resolvePlayerScript,
399
+ formatOptions:
400
+ target.formatOptions === undefined
401
+ ? prepared.formatOptions
402
+ : { ...(prepared.formatOptions ?? {}), ...target.formatOptions },
348
403
  };
349
404
  }
405
+
406
+ function mergeAbortSignals(
407
+ preparedSignal?: AbortSignal,
408
+ targetSignal?: AbortSignal,
409
+ ): AbortSignal | undefined {
410
+ if (!preparedSignal) return targetSignal;
411
+ if (!targetSignal || preparedSignal === targetSignal) return preparedSignal;
412
+ return AbortSignal.any([preparedSignal, targetSignal]);
413
+ }
414
+
415
+ /**
416
+ * Convert a source document to a target format.
417
+ *
418
+ * @param source - A bytes / markdown / doc source.
419
+ * @param to - The target format id (must be registered and support export).
420
+ * @param options - Conversion options (registry, theme, transform, …).
421
+ * @throws {@link ConversionError} on any failure, with a stable `code`.
422
+ */
423
+ export async function convert(
424
+ source: ConvertSource,
425
+ to: FormatId,
426
+ options: ConvertOptions = {},
427
+ ): Promise<ConversionResult> {
428
+ throwIfAborted(options.signal);
429
+ const registry = options.registry ?? defaultRegistry();
430
+ requireExportTarget(registry, to);
431
+ const prepared = await prepareConversionInternal(source, { ...options, registry }, to);
432
+ return prepared.convert(to);
433
+ }
@@ -399,9 +399,12 @@ export function defaultFormats(): FormatDefinition[] {
399
399
  const { zipToContainer } = await import('../container/index.js');
400
400
  return zipToContainer(data, optionsFor(options, 'dbk'));
401
401
  },
402
- async exportDoc(input): Promise<ConversionResult> {
402
+ async exportDoc(input, options): Promise<ConversionResult> {
403
403
  const { containerToZip } = await import('../container/index.js');
404
- const blob = await containerToZip(await snapshotContainerWithDocument(input));
404
+ const blob = await containerToZip(
405
+ await snapshotContainerWithDocument(input),
406
+ optionsFor(options, 'dbk'),
407
+ );
405
408
  return ok(await toBytes(blob), MIME.zip);
406
409
  },
407
410
  };
@@ -30,6 +30,8 @@ export type {
30
30
  BuiltinFormatOptions,
31
31
  MarkdownFormatOptions,
32
32
  DbkFormatOptions,
33
+ PreparedConversion,
34
+ PreparedExportOptions,
33
35
  } from './types.js';
34
36
  export { BUILTIN_FORMAT_IDS } from './types.js';
35
37
 
@@ -38,4 +40,4 @@ export type { ConversionErrorCode, ConversionErrorOptions } from './errors.js';
38
40
 
39
41
  export { createRegistry, defaultRegistry } from './registry.js';
40
42
  export { defaultFormats } from './defaultFormats.js';
41
- export { convert } from './convert.js';
43
+ export { convert, prepareConversion } from './convert.js';
@@ -25,6 +25,7 @@ import type { PdfImportOptions } from '../pdf/import.js';
25
25
  import type { HtmlExportOptions } from '../html/htmlTemplate.js';
26
26
  import type { HtmlImportOptions } from '../html/import.js';
27
27
  import type { EpubExportOptions } from '../epub/export.js';
28
+ import type { ContainerToZipOptions } from '../container/index.js';
28
29
  import type { ZipSafetyLimits } from '../shared/zipSafety.js';
29
30
 
30
31
  /** A format identifier (e.g. `'docx'`). Strings so hosts can register their own. */
@@ -62,7 +63,7 @@ export interface MarkdownFormatOptions {
62
63
  }
63
64
 
64
65
  /** Resource limits applied when importing a DBK/ZIP container. */
65
- export type DbkFormatOptions = ZipSafetyLimits;
66
+ export type DbkFormatOptions = ZipSafetyLimits & ContainerToZipOptions;
66
67
 
67
68
  /**
68
69
  * Strongly typed option bags for built-in formats. Import and export options
@@ -97,6 +98,8 @@ export interface NormalizedInput {
97
98
 
98
99
  /** Options threaded through `convert()` and into every format method. */
99
100
  export interface ConvertOptions {
101
+ /** Cancel normalization, transformation, or export at the next bounded work boundary. */
102
+ signal?: AbortSignal;
100
103
  /** Registry to resolve formats against. Defaults to `defaultRegistry()`. */
101
104
  registry?: FormatRegistry;
102
105
  /** Explicit source format id (skips extension/byte sniffing). */
@@ -119,6 +122,20 @@ export interface ConvertOptions {
119
122
  formatOptions?: Partial<BuiltinFormatOptions> & Record<FormatId, unknown>;
120
123
  }
121
124
 
125
+ /** Target-only options accepted after a source has already been normalized and transformed. */
126
+ export type PreparedExportOptions = Pick<
127
+ ConvertOptions,
128
+ 'signal' | 'title' | 'resolvePlayerScript' | 'formatOptions'
129
+ >;
130
+
131
+ /**
132
+ * An opaque normalized conversion snapshot. The source and transform pipeline
133
+ * has already completed; each call exports that same snapshot to one target.
134
+ */
135
+ export interface PreparedConversion {
136
+ convert(to: FormatId, options?: PreparedExportOptions): Promise<ConversionResult>;
137
+ }
138
+
122
139
  /** Describes how a single format imports to / exports from the squisq model. */
123
140
  export interface FormatDefinition {
124
141
  id: FormatId;
@@ -8,6 +8,7 @@ import {
8
8
  compressionRatioError,
9
9
  entryTooLargeError,
10
10
  resolveZipSafetyLimits,
11
+ throwIfZipAborted,
11
12
  validateZipArchive,
12
13
  tooManyEntriesError,
13
14
  ZipSafetyError,
@@ -19,7 +20,7 @@ import {
19
20
  import { declaredZipEntryCount, prepareZipInput, type ZipInput } from './zipEntryCount.js';
20
21
 
21
22
  interface ActiveZipRead {
22
- stop(error: ZipSafetyError): void;
23
+ stop(error: unknown): void;
23
24
  }
24
25
 
25
26
  interface ZipChunkStream {
@@ -58,15 +59,22 @@ class JsZipBoundedArchive implements BoundedZipArchive {
58
59
 
59
60
  private readonly limits: ResolvedZipSafetyLimits;
60
61
  private readonly entriesByPath: ReadonlyMap<string, ValidatedZipEntry>;
62
+ private readonly signal?: AbortSignal;
61
63
  private readonly reads = new Map<string, Promise<Uint8Array>>();
62
64
  private readonly activeReads = new Set<ActiveZipRead>();
63
65
  private terminalError?: ZipSafetyError;
64
66
  private emittedBytes = 0;
65
67
 
66
- constructor(zip: JSZip, entries: readonly ValidatedZipEntry[], limits: ResolvedZipSafetyLimits) {
68
+ constructor(
69
+ zip: JSZip,
70
+ entries: readonly ValidatedZipEntry[],
71
+ limits: ResolvedZipSafetyLimits,
72
+ signal?: AbortSignal,
73
+ ) {
67
74
  this.zip = zip;
68
75
  this.entries = entries;
69
76
  this.limits = limits;
77
+ this.signal = signal;
70
78
  this.entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
71
79
  }
72
80
 
@@ -77,6 +85,7 @@ class JsZipBoundedArchive implements BoundedZipArchive {
77
85
 
78
86
  /** Read and cache one member, returning null when it does not exist. */
79
87
  async read(path: string, maxBytes?: number): Promise<Uint8Array | null> {
88
+ throwIfZipAborted(this.signal);
80
89
  this.throwIfFailed();
81
90
  if (maxBytes !== undefined) assertNonNegativeFinite('member read limit', maxBytes);
82
91
  const entry = this.entriesByPath.get(path);
@@ -87,7 +96,16 @@ class JsZipBoundedArchive implements BoundedZipArchive {
87
96
  pending = this.streamEntry(entry, maxBytes);
88
97
  this.reads.set(path, pending);
89
98
  }
90
- const bytes = await pending;
99
+ let bytes: Uint8Array;
100
+ try {
101
+ bytes = await pending;
102
+ } catch (error: unknown) {
103
+ // Cancellation is scoped to the current operation, not archive
104
+ // corruption. Do not leave a cancelled read cached for a later caller.
105
+ if (this.reads.get(path) === pending && !this.terminalError) this.reads.delete(path);
106
+ throw error;
107
+ }
108
+ throwIfZipAborted(this.signal);
91
109
  if (maxBytes !== undefined && bytes.byteLength > maxBytes) {
92
110
  const error = entryTooLargeError(path, bytes.byteLength, maxBytes);
93
111
  this.failArchive(error);
@@ -108,6 +126,7 @@ class JsZipBoundedArchive implements BoundedZipArchive {
108
126
  }
109
127
 
110
128
  private streamEntry(metadata: ValidatedZipEntry, readLimit?: number): Promise<Uint8Array> {
129
+ throwIfZipAborted(this.signal);
111
130
  const { path, entry, declaredSize, crc32: expectedCrc } = metadata;
112
131
  const effectiveEntryLimit = Math.min(
113
132
  this.limits.maxEntryUncompressedBytes,
@@ -129,12 +148,17 @@ class JsZipBoundedArchive implements BoundedZipArchive {
129
148
  let settled = false;
130
149
  let written = 0;
131
150
  let crc = 0xffffffff;
151
+ const removeAbortListener = (): void =>
152
+ this.signal?.removeEventListener('abort', handleAbort);
153
+ const handleAbort = (): void =>
154
+ controller.stop(this.signal?.reason ?? new Error('ZIP operation was cancelled'));
132
155
 
133
156
  const controller: ActiveZipRead = {
134
157
  stop: (error) => {
135
158
  if (settled) return;
136
159
  settled = true;
137
160
  this.activeReads.delete(controller);
161
+ removeAbortListener();
138
162
  // Public JSZip cancellation is cooperative. It prevents another
139
163
  // compressed-input tick; data already emitted synchronously by the
140
164
  // current tick is ignored because `settled` is now true.
@@ -148,19 +172,28 @@ class JsZipBoundedArchive implements BoundedZipArchive {
148
172
  },
149
173
  };
150
174
  this.activeReads.add(controller);
175
+ if (this.signal) {
176
+ this.signal.addEventListener('abort', handleAbort, { once: true });
177
+ if (this.signal.aborted) {
178
+ handleAbort();
179
+ return;
180
+ }
181
+ }
151
182
 
152
183
  stream
153
184
  .on('data', (chunk: Uint8Array) => {
154
185
  if (settled) return;
155
186
  const nextWritten = written + chunk.byteLength;
156
187
  try {
188
+ throwIfZipAborted(this.signal);
157
189
  this.assertChunkAllowed(metadata, nextWritten, chunk.byteLength, effectiveEntryLimit);
158
190
  output = growOutput(output, nextWritten, declaredSize, effectiveEntryLimit);
159
191
  output.set(chunk, written);
160
192
  written = nextWritten;
161
193
  crc = updateCrc32(crc, chunk);
162
194
  } catch (error: unknown) {
163
- this.failArchive(asZipSafetyError(error, path));
195
+ if (this.signal?.aborted) controller.stop(error);
196
+ else this.failArchive(asZipSafetyError(error, path));
164
197
  }
165
198
  })
166
199
  .on('error', (error: unknown) => {
@@ -200,6 +233,7 @@ class JsZipBoundedArchive implements BoundedZipArchive {
200
233
 
201
234
  settled = true;
202
235
  this.activeReads.delete(controller);
236
+ removeAbortListener();
203
237
  resolve(output.byteLength === written ? output : output.slice(0, written));
204
238
  })
205
239
  .resume();
@@ -260,13 +294,16 @@ export async function openBoundedZipArchive(
260
294
  data: ZipInput,
261
295
  limits: ZipSafetyLimits = {},
262
296
  ): Promise<BoundedZipArchive> {
297
+ throwIfZipAborted(limits.signal);
263
298
  const resolved = resolveZipSafetyLimits(limits);
264
299
  let prepared: { input: ZipInput; bytes?: Uint8Array };
265
300
  try {
266
- prepared = await prepareZipInput(data);
301
+ prepared = await prepareZipInput(data, limits.signal);
267
302
  } catch (error: unknown) {
303
+ if (limits.signal?.aborted) throw limits.signal.reason ?? error;
268
304
  throw invalidArchiveError(error);
269
305
  }
306
+ throwIfZipAborted(limits.signal);
270
307
  if (prepared.bytes) {
271
308
  const declaredEntries = declaredZipEntryCount(prepared.bytes);
272
309
  if (declaredEntries !== undefined && declaredEntries > resolved.maxEntries) {
@@ -277,9 +314,11 @@ export async function openBoundedZipArchive(
277
314
  try {
278
315
  zip = await JSZip.loadAsync(prepared.input);
279
316
  } catch (error: unknown) {
317
+ if (limits.signal?.aborted) throw limits.signal.reason ?? error;
280
318
  throw invalidArchiveError(error);
281
319
  }
282
- return createBoundedZipArchive(zip, resolved);
320
+ throwIfZipAborted(limits.signal);
321
+ return createBoundedZipArchive(zip, limits);
283
322
  }
284
323
 
285
324
  function invalidArchiveError(cause: unknown): ZipSafetyError {
@@ -291,9 +330,11 @@ export function createBoundedZipArchive(
291
330
  zip: JSZip,
292
331
  limits: ZipSafetyLimits = {},
293
332
  ): BoundedZipArchive {
333
+ throwIfZipAborted(limits.signal);
294
334
  const resolved = resolveZipSafetyLimits(limits);
295
- const entries = validateZipArchive(zip, resolved);
296
- return new JsZipBoundedArchive(zip, entries, resolved);
335
+ const entries = validateZipArchive(zip, limits);
336
+ throwIfZipAborted(limits.signal);
337
+ return new JsZipBoundedArchive(zip, entries, resolved, limits.signal);
297
338
  }
298
339
 
299
340
  function asZipSafetyError(error: unknown, path: string): ZipSafetyError {
@@ -1,5 +1,7 @@
1
1
  /** Narrow EOCD preflight for rejecting record-count bombs before JSZip allocation. */
2
2
 
3
+ import { throwIfZipAborted } from './zipLimits.js';
4
+
3
5
  const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
4
6
  const ZIP64_END_OF_CENTRAL_DIRECTORY = 0x06064b50;
5
7
  const ZIP64_END_LOCATOR = 0x07064b50;
@@ -12,7 +14,9 @@ export type ZipInput = ArrayBuffer | Uint8Array | Blob;
12
14
  /** Materialize Blob input once when possible so preflight bytes feed JSZip too. */
13
15
  export async function prepareZipInput(
14
16
  data: ZipInput,
17
+ signal?: AbortSignal,
15
18
  ): Promise<{ input: ZipInput; bytes?: Uint8Array }> {
19
+ throwIfZipAborted(signal);
16
20
  if (ArrayBuffer.isView(data)) {
17
21
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
18
22
  return { input: bytes, bytes };
@@ -22,7 +26,8 @@ export async function prepareZipInput(
22
26
  return { input: bytes, bytes };
23
27
  }
24
28
 
25
- const bytes = await readBlobBytes(data);
29
+ const bytes = await readBlobBytes(data, signal);
30
+ throwIfZipAborted(signal);
26
31
  return bytes ? { input: bytes, bytes } : { input: data };
27
32
  }
28
33
 
@@ -82,16 +87,65 @@ function isBlobLike(data: ZipInput): data is Blob {
82
87
  return typeof candidate.size === 'number' && typeof candidate.slice === 'function';
83
88
  }
84
89
 
85
- async function readBlobBytes(blob: Blob): Promise<Uint8Array | undefined> {
90
+ async function readBlobBytes(blob: Blob, signal?: AbortSignal): Promise<Uint8Array | undefined> {
86
91
  const arrayBuffer = (blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer;
87
92
  if (typeof arrayBuffer === 'function') {
88
- return new Uint8Array(await arrayBuffer.call(blob));
93
+ return new Uint8Array(await raceWithAbort(arrayBuffer.call(blob), signal));
89
94
  }
90
95
  if (typeof FileReader === 'undefined') return undefined;
91
96
  return new Promise((resolve, reject) => {
92
97
  const reader = new FileReader();
93
- reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
94
- reader.onerror = () => reject(reader.error ?? new Error('Could not read ZIP blob.'));
98
+ let settled = false;
99
+ const cleanup = (): void => signal?.removeEventListener('abort', handleAbort);
100
+ const finish = (work: () => void): void => {
101
+ if (settled) return;
102
+ settled = true;
103
+ cleanup();
104
+ work();
105
+ };
106
+ const handleAbort = (): void => {
107
+ if (settled) return;
108
+ const reason = signal?.reason ?? new Error('ZIP operation was cancelled');
109
+ // Mark the promise settled before FileReader.abort(): browsers are
110
+ // allowed to dispatch `abort` synchronously and re-enter this handler.
111
+ finish(() => reject(reason));
112
+ try {
113
+ reader.abort();
114
+ } catch {
115
+ // Preserve the caller's reason if a platform reader is already done.
116
+ }
117
+ };
118
+ reader.onload = () => finish(() => resolve(new Uint8Array(reader.result as ArrayBuffer)));
119
+ reader.onerror = () =>
120
+ finish(() => reject(reader.error ?? new Error('Could not read ZIP blob.')));
121
+ reader.onabort = handleAbort;
122
+ signal?.addEventListener('abort', handleAbort, { once: true });
123
+ if (signal?.aborted) {
124
+ handleAbort();
125
+ return;
126
+ }
95
127
  reader.readAsArrayBuffer(blob);
96
128
  });
97
129
  }
130
+
131
+ function raceWithAbort<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
132
+ throwIfZipAborted(signal);
133
+ if (!signal) return work;
134
+ return new Promise<T>((resolve, reject) => {
135
+ let settled = false;
136
+ const finish = (complete: () => void): void => {
137
+ if (settled) return;
138
+ settled = true;
139
+ signal.removeEventListener('abort', handleAbort);
140
+ complete();
141
+ };
142
+ const handleAbort = (): void =>
143
+ finish(() => reject(signal.reason ?? new Error('ZIP operation was cancelled')));
144
+ signal.addEventListener('abort', handleAbort, { once: true });
145
+ if (signal.aborted) handleAbort();
146
+ work.then(
147
+ (value) => finish(() => resolve(value)),
148
+ (error: unknown) => finish(() => reject(error)),
149
+ );
150
+ });
151
+ }
@@ -3,6 +3,8 @@
3
3
  import type JSZip from 'jszip';
4
4
 
5
5
  export interface ZipSafetyLimits {
6
+ /** Cooperatively cancel archive opening, validation, and member traversal. */
7
+ signal?: AbortSignal;
6
8
  /** Maximum number of archive records, including directories. Default: 10,000. */
7
9
  maxEntries?: number;
8
10
  /** Maximum uncompressed bytes for one member. Defaults to maxUncompressedBytes. */
@@ -77,6 +79,7 @@ export const DEFAULT_MAX_ZIP_COMPRESSION_RATIO = 1_000;
77
79
  * JSZip normalizes traversal names, so inspect `unsafeOriginalName` too.
78
80
  */
79
81
  export function validateZipArchive(zip: JSZip, limits: ZipSafetyLimits = {}): ValidatedZipEntry[] {
82
+ throwIfZipAborted(limits.signal);
80
83
  const resolved = resolveZipSafetyLimits(limits);
81
84
  const files: ValidatedZipEntry[] = [];
82
85
  let memberCount = 0;
@@ -84,6 +87,7 @@ export function validateZipArchive(zip: JSZip, limits: ZipSafetyLimits = {}): Va
84
87
  let oversizedEntry: { path: string; actual: number; limit: number } | undefined;
85
88
 
86
89
  zip.forEach((relativePath, entry) => {
90
+ throwIfZipAborted(limits.signal);
87
91
  memberCount++;
88
92
  if (entry.dir) return;
89
93
  const originalPath = originalEntryPath(entry, relativePath);
@@ -140,6 +144,12 @@ export function validateZipArchive(zip: JSZip, limits: ZipSafetyLimits = {}): Va
140
144
  return files;
141
145
  }
142
146
 
147
+ /** Preserve the caller's exact cancellation reason across ZIP/OOXML layers. */
148
+ export function throwIfZipAborted(signal?: AbortSignal): void {
149
+ if (!signal?.aborted) return;
150
+ throw signal.reason ?? new Error('ZIP operation was cancelled');
151
+ }
152
+
143
153
  /** Reject a path that could be interpreted outside an archive's logical root. */
144
154
  export function assertSafeZipPath(path: string): void {
145
155
  if (!isSafeArchivePath(path)) {