@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.
- package/dist/{chunk-CX3LH47J.js → chunk-74GO3FVS.js} +3 -3
- package/dist/{chunk-4HJ6AK2Q.js → chunk-7ARKUCQT.js} +52 -13
- package/dist/chunk-7ARKUCQT.js.map +1 -0
- package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
- package/dist/chunk-DTDF6QDP.js.map +1 -0
- package/dist/{chunk-ZZ7D4DN6.js → chunk-EW54IRRS.js} +39 -9
- package/dist/chunk-EW54IRRS.js.map +1 -0
- package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-XTLQXR4U.js → chunk-KURGXM4I.js} +3 -3
- package/dist/{chunk-2CSZJEAL.js → chunk-NNHKUXKA.js} +292 -70
- package/dist/chunk-NNHKUXKA.js.map +1 -0
- package/dist/{chunk-2PRVLAU3.js → chunk-QFLDYKCR.js} +55 -17
- package/dist/chunk-QFLDYKCR.js.map +1 -0
- package/dist/{chunk-CVNVD7VH.js → chunk-RLU7UFYU.js} +5 -5
- package/dist/{chunk-X4USWOQ2.js → chunk-SC67HYQJ.js} +2 -2
- package/dist/{chunk-HBSCRS5P.js → chunk-UGYF5AZE.js} +3 -3
- package/dist/{chunk-2EFDWKC6.js → chunk-WQSHGBLN.js} +86 -23
- package/dist/chunk-WQSHGBLN.js.map +1 -0
- package/dist/container/index.d.ts +10 -4
- package/dist/container/index.js +9 -4
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +2 -2
- package/dist/docx/index.js +5 -4
- package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
- package/dist/{extract-LNOA7DBK.js → extract-OJ7ZQV6P.js} +5 -5
- package/dist/html/index.js +1 -1
- package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +15 -13
- package/dist/infer/index.d.ts +4 -4
- package/dist/infer/index.js +5 -5
- package/dist/{layouts-7OUUVAGU.js → layouts-5VDIRPIJ.js} +4 -4
- package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
- package/dist/ooxml/index.d.ts +4 -4
- package/dist/ooxml/index.js +4 -4
- package/dist/pptx/index.d.ts +5 -5
- package/dist/pptx/index.js +5 -5
- package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
- package/dist/registry/index.d.ts +20 -6
- package/dist/registry/index.js +6 -4
- package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
- package/dist/xlsx/index.d.ts +4 -4
- package/dist/xlsx/index.js +4 -4
- package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
- package/package.json +2 -2
- package/src/__tests__/convert.test.ts +143 -1
- package/src/__tests__/docxExport.test.ts +34 -0
- package/src/__tests__/docxImport.test.ts +121 -0
- package/src/__tests__/html.test.ts +4 -0
- package/src/__tests__/ooxml.test.ts +17 -0
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/container/index.ts +17 -3
- package/src/docx/export.ts +113 -5
- package/src/docx/import.ts +324 -69
- package/src/html/htmlTemplate.ts +2 -2
- package/src/index.ts +3 -0
- package/src/infer/index.ts +35 -4
- package/src/ooxml/namespaces.ts +21 -0
- package/src/ooxml/reader.ts +55 -14
- package/src/pptx/layouts.ts +68 -14
- package/src/registry/convert.ts +115 -31
- package/src/registry/defaultFormats.ts +5 -2
- package/src/registry/index.ts +3 -1
- package/src/registry/types.ts +18 -1
- package/src/shared/boundedZipArchive.ts +49 -8
- package/src/shared/zipEntryCount.ts +59 -5
- package/src/shared/zipLimits.ts +10 -0
- package/dist/chunk-2CSZJEAL.js.map +0 -1
- package/dist/chunk-2EFDWKC6.js.map +0 -1
- package/dist/chunk-2PRVLAU3.js.map +0 -1
- package/dist/chunk-4HJ6AK2Q.js.map +0 -1
- package/dist/chunk-H7T5DXUU.js.map +0 -1
- package/dist/chunk-LXYLOOST.js.map +0 -1
- package/dist/chunk-ZZ7D4DN6.js.map +0 -1
- /package/dist/{chunk-CX3LH47J.js.map → chunk-74GO3FVS.js.map} +0 -0
- /package/dist/{chunk-XTLQXR4U.js.map → chunk-KURGXM4I.js.map} +0 -0
- /package/dist/{chunk-CVNVD7VH.js.map → chunk-RLU7UFYU.js.map} +0 -0
- /package/dist/{chunk-X4USWOQ2.js.map → chunk-SC67HYQJ.js.map} +0 -0
- /package/dist/{chunk-HBSCRS5P.js.map → chunk-UGYF5AZE.js.map} +0 -0
- /package/dist/{extract-LNOA7DBK.js.map → extract-OJ7ZQV6P.js.map} +0 -0
- /package/dist/{layouts-7OUUVAGU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
package/src/registry/convert.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
292
|
-
|
|
293
|
+
): Promise<PreparedConversion> {
|
|
294
|
+
return prepareConversionInternal(source, options);
|
|
295
|
+
}
|
|
293
296
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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, {
|
|
341
|
+
throw new ConversionError('conversion-failed', message, {
|
|
342
|
+
format: failureFormat,
|
|
343
|
+
cause: err,
|
|
344
|
+
});
|
|
341
345
|
}
|
|
342
346
|
|
|
343
|
-
const
|
|
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
|
-
...
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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
|
};
|
package/src/registry/index.ts
CHANGED
|
@@ -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';
|
package/src/registry/types.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
296
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
+
}
|
package/src/shared/zipLimits.ts
CHANGED
|
@@ -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)) {
|