@bendyline/squisq-formats 2.0.0 → 2.0.1

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 (70) hide show
  1. package/dist/{chunk-CX3LH47J.js → chunk-2KPARF2P.js} +3 -3
  2. package/dist/{chunk-CVNVD7VH.js → chunk-ABVI556T.js} +5 -5
  3. package/dist/{chunk-2CSZJEAL.js → chunk-CRAVSMPZ.js} +3 -3
  4. package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
  5. package/dist/chunk-FE6OJV6O.js.map +1 -0
  6. package/dist/{chunk-2EFDWKC6.js → chunk-HTW2M27H.js} +86 -23
  7. package/dist/chunk-HTW2M27H.js.map +1 -0
  8. package/dist/{chunk-ZZ7D4DN6.js → chunk-QRVN6A6E.js} +39 -9
  9. package/dist/chunk-QRVN6A6E.js.map +1 -0
  10. package/dist/{chunk-XTLQXR4U.js → chunk-U32AG3G3.js} +3 -3
  11. package/dist/{chunk-HBSCRS5P.js → chunk-VJJM2SSH.js} +3 -3
  12. package/dist/{chunk-2PRVLAU3.js → chunk-VSYHZECT.js} +55 -17
  13. package/dist/chunk-VSYHZECT.js.map +1 -0
  14. package/dist/{chunk-4HJ6AK2Q.js → chunk-WC7WULGV.js} +44 -13
  15. package/dist/chunk-WC7WULGV.js.map +1 -0
  16. package/dist/{chunk-X4USWOQ2.js → chunk-XKUMNGBW.js} +2 -2
  17. package/dist/container/index.d.ts +10 -4
  18. package/dist/container/index.js +9 -4
  19. package/dist/container/index.js.map +1 -1
  20. package/dist/docx/index.d.ts +2 -2
  21. package/dist/docx/index.js +4 -4
  22. package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
  23. package/dist/{extract-LNOA7DBK.js → extract-H6RXJMHP.js} +5 -5
  24. package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
  25. package/dist/index.d.ts +8 -7
  26. package/dist/index.js +14 -12
  27. package/dist/infer/index.d.ts +4 -4
  28. package/dist/infer/index.js +5 -5
  29. package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
  30. package/dist/{layouts-7OUUVAGU.js → layouts-QVPK3ZCU.js} +4 -4
  31. package/dist/ooxml/index.d.ts +4 -4
  32. package/dist/ooxml/index.js +4 -4
  33. package/dist/pptx/index.d.ts +5 -5
  34. package/dist/pptx/index.js +5 -5
  35. package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
  36. package/dist/registry/index.d.ts +20 -6
  37. package/dist/registry/index.js +6 -4
  38. package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
  39. package/dist/xlsx/index.d.ts +4 -4
  40. package/dist/xlsx/index.js +4 -4
  41. package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
  42. package/package.json +2 -2
  43. package/src/__tests__/convert.test.ts +143 -1
  44. package/src/__tests__/ooxml.test.ts +17 -0
  45. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  46. package/src/container/index.ts +17 -3
  47. package/src/index.ts +3 -0
  48. package/src/infer/index.ts +35 -4
  49. package/src/ooxml/reader.ts +55 -14
  50. package/src/pptx/layouts.ts +68 -14
  51. package/src/registry/convert.ts +115 -31
  52. package/src/registry/defaultFormats.ts +5 -2
  53. package/src/registry/index.ts +3 -1
  54. package/src/registry/types.ts +18 -1
  55. package/src/shared/boundedZipArchive.ts +49 -8
  56. package/src/shared/zipEntryCount.ts +59 -5
  57. package/src/shared/zipLimits.ts +10 -0
  58. package/dist/chunk-2EFDWKC6.js.map +0 -1
  59. package/dist/chunk-2PRVLAU3.js.map +0 -1
  60. package/dist/chunk-4HJ6AK2Q.js.map +0 -1
  61. package/dist/chunk-H7T5DXUU.js.map +0 -1
  62. package/dist/chunk-ZZ7D4DN6.js.map +0 -1
  63. /package/dist/{chunk-CX3LH47J.js.map → chunk-2KPARF2P.js.map} +0 -0
  64. /package/dist/{chunk-CVNVD7VH.js.map → chunk-ABVI556T.js.map} +0 -0
  65. /package/dist/{chunk-2CSZJEAL.js.map → chunk-CRAVSMPZ.js.map} +0 -0
  66. /package/dist/{chunk-XTLQXR4U.js.map → chunk-U32AG3G3.js.map} +0 -0
  67. /package/dist/{chunk-HBSCRS5P.js.map → chunk-VJJM2SSH.js.map} +0 -0
  68. /package/dist/{chunk-X4USWOQ2.js.map → chunk-XKUMNGBW.js.map} +0 -0
  69. /package/dist/{extract-LNOA7DBK.js.map → extract-H6RXJMHP.js.map} +0 -0
  70. /package/dist/{layouts-7OUUVAGU.js.map → layouts-QVPK3ZCU.js.map} +0 -0
@@ -24,7 +24,12 @@ import type {
24
24
  TextLayer,
25
25
  TextStyle,
26
26
  } from '@bendyline/squisq/schemas';
27
- import { getPartRelationships, getPartXml, openPackage } from '../ooxml/reader.js';
27
+ import {
28
+ getPartRelationships,
29
+ getPartXml,
30
+ openPackage,
31
+ throwIfOoxmlAborted,
32
+ } from '../ooxml/reader.js';
28
33
  import type { OoxmlOpenOptions } from '../ooxml/reader.js';
29
34
  import type { OoxmlPackage } from '../ooxml/types.js';
30
35
  import {
@@ -162,6 +167,7 @@ const DEFAULT_SLIDE_SIZE = { cx: 12192000, cy: 6858000 };
162
167
  const CHROME_PH_TYPES = new Set(['dt', 'ftr', 'sldNum', 'hdr']);
163
168
  const MAX_INFERRED_TEMPLATES = 12;
164
169
  const MAX_DECORATIONS = 4;
170
+ const CANCELLATION_CHECKPOINT_INTERVAL = 128;
165
171
  /** Decorative rects smaller than this share of the slide are clutter. */
166
172
  const MIN_DECORATION_AREA_PCT = 2;
167
173
 
@@ -173,6 +179,14 @@ const DEFAULT_TITLE_PT = 44;
173
179
  const DEFAULT_SUBTITLE_PT = 24;
174
180
  const DEFAULT_BODY_PT = 22;
175
181
 
182
+ async function cancellationCheckpoint(index: number, signal?: AbortSignal): Promise<void> {
183
+ throwIfOoxmlAborted(signal);
184
+ if (index > 0 && index % CANCELLATION_CHECKPOINT_INTERVAL === 0) {
185
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
186
+ throwIfOoxmlAborted(signal);
187
+ }
188
+ }
189
+
176
190
  // ── Small helpers ────────────────────────────────────────────────────
177
191
 
178
192
  function firstPml(parent: Element | Document, local: string): Element | null {
@@ -316,7 +330,12 @@ interface MasterIndex {
316
330
  bodyStyle?: PlaceholderTextStyle;
317
331
  }
318
332
 
319
- async function readMasterIndex(pkg: OoxmlPackage, masterPath: string): Promise<MasterIndex> {
333
+ async function readMasterIndex(
334
+ pkg: OoxmlPackage,
335
+ masterPath: string,
336
+ signal?: AbortSignal,
337
+ ): Promise<MasterIndex> {
338
+ throwIfOoxmlAborted(signal);
320
339
  const index: MasterIndex = { name: '', exact: new Map(), baseIdx: new Map(), base: new Map() };
321
340
  const doc = await getPartXml(pkg, masterPath);
322
341
  if (!doc) return index;
@@ -333,6 +352,7 @@ async function readMasterIndex(pkg: OoxmlPackage, masterPath: string): Promise<M
333
352
 
334
353
  const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
335
354
  for (let i = 0; i < shapes.length; i++) {
355
+ await cancellationCheckpoint(i, signal);
336
356
  const sp = shapes[i]!;
337
357
  const ph = firstPml(sp, 'ph');
338
358
  if (!ph) continue;
@@ -425,7 +445,9 @@ async function extractLayout(
425
445
  pxPerPt: number,
426
446
  hints: PptxColorHints,
427
447
  masterCache: Map<string, Promise<MasterIndex>>,
448
+ signal?: AbortSignal,
428
449
  ): Promise<ExtractedSlideLayout | null> {
450
+ throwIfOoxmlAborted(signal);
429
451
  const doc = await getPartXml(pkg, layoutPath);
430
452
  if (!doc) return null;
431
453
 
@@ -440,7 +462,7 @@ async function extractLayout(
440
462
  masterPath = resolveTarget(baseDirOf(layoutPath), masterRel.target);
441
463
  let cached = masterCache.get(masterPath);
442
464
  if (!cached) {
443
- cached = readMasterIndex(pkg, masterPath);
465
+ cached = readMasterIndex(pkg, masterPath, signal);
444
466
  masterCache.set(masterPath, cached);
445
467
  }
446
468
  master = await cached;
@@ -451,6 +473,7 @@ async function extractLayout(
451
473
 
452
474
  const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
453
475
  for (let i = 0; i < shapes.length; i++) {
476
+ await cancellationCheckpoint(i, signal);
454
477
  const sp = shapes[i]!;
455
478
  const ph = firstPml(sp, 'ph');
456
479
 
@@ -1003,7 +1026,11 @@ function resolveVerdict(
1003
1026
 
1004
1027
  // ── Deck-level analysis ──────────────────────────────────────────────
1005
1028
 
1006
- async function readSlideSize(pkg: OoxmlPackage): Promise<{ cx: number; cy: number }> {
1029
+ async function readSlideSize(
1030
+ pkg: OoxmlPackage,
1031
+ signal?: AbortSignal,
1032
+ ): Promise<{ cx: number; cy: number }> {
1033
+ throwIfOoxmlAborted(signal);
1007
1034
  const pres = await getPartXml(pkg, 'ppt/presentation.xml');
1008
1035
  const sldSz = pres ? firstPml(pres, 'sldSz') : null;
1009
1036
  const cx = intAttr(sldSz, 'cx');
@@ -1012,7 +1039,11 @@ async function readSlideSize(pkg: OoxmlPackage): Promise<{ cx: number; cy: numbe
1012
1039
  return { ...DEFAULT_SLIDE_SIZE };
1013
1040
  }
1014
1041
 
1015
- async function orderedSlidePathsForLayouts(pkg: OoxmlPackage): Promise<string[]> {
1042
+ async function orderedSlidePathsForLayouts(
1043
+ pkg: OoxmlPackage,
1044
+ signal?: AbortSignal,
1045
+ ): Promise<string[]> {
1046
+ throwIfOoxmlAborted(signal);
1016
1047
  const pres = await getPartXml(pkg, 'ppt/presentation.xml');
1017
1048
  if (!pres) return [];
1018
1049
  const rels = await getPartRelationships(pkg, 'ppt/presentation.xml');
@@ -1020,6 +1051,7 @@ async function orderedSlidePathsForLayouts(pkg: OoxmlPackage): Promise<string[]>
1020
1051
  const out: string[] = [];
1021
1052
  const ids = pres.getElementsByTagNameNS(NS_PML, 'sldId');
1022
1053
  for (let i = 0; i < ids.length; i++) {
1054
+ await cancellationCheckpoint(i, signal);
1023
1055
  const rid = attrNS(ids[i]!, NS_R, 'id', 'r:id');
1024
1056
  const target = rid ? relById.get(rid) : undefined;
1025
1057
  if (target) out.push(resolveTarget('ppt', target));
@@ -1043,20 +1075,23 @@ export async function analyzePptxLayouts(
1043
1075
  pkg: OoxmlPackage,
1044
1076
  options: AnalyzePptxLayoutsOptions = {},
1045
1077
  ): Promise<PptxLayoutInference> {
1078
+ throwIfOoxmlAborted(options.signal);
1046
1079
  const warnings: string[] = [];
1047
1080
  const hints = options.colors ?? {};
1048
1081
  const maxTemplates = options.maxTemplates ?? MAX_INFERRED_TEMPLATES;
1049
1082
 
1050
- const sldSz = await readSlideSize(pkg);
1083
+ const sldSz = await readSlideSize(pkg, options.signal);
1051
1084
  const viewport = viewportFor(sldSz);
1052
1085
  const pxPerPt = viewport.height / (sldSz.cy / 12700);
1053
1086
 
1054
1087
  // Layouts used by slides, in first-use order, with usage counts.
1055
- const slidePaths = await orderedSlidePathsForLayouts(pkg);
1088
+ const slidePaths = await orderedSlidePathsForLayouts(pkg, options.signal);
1056
1089
  const layoutPathBySlide = new Map<string, string>();
1057
1090
  const usage = new Map<string, number>();
1058
1091
  const orderedLayoutPaths: string[] = [];
1059
- for (const slidePath of slidePaths) {
1092
+ for (let slideIndex = 0; slideIndex < slidePaths.length; slideIndex++) {
1093
+ await cancellationCheckpoint(slideIndex, options.signal);
1094
+ const slidePath = slidePaths[slideIndex]!;
1060
1095
  const rels = await getPartRelationships(pkg, slidePath);
1061
1096
  const layoutRel = findRelByType(rels, REL_SLIDE_LAYOUT);
1062
1097
  if (!layoutRel) continue;
@@ -1068,7 +1103,9 @@ export async function analyzePptxLayouts(
1068
1103
 
1069
1104
  if (options.includeUnused) {
1070
1105
  const unused: string[] = [];
1106
+ let partIndex = 0;
1071
1107
  for (const [partPath, contentType] of pkg.contentTypes.overrides) {
1108
+ await cancellationCheckpoint(partIndex++, options.signal);
1072
1109
  if (contentType === CONTENT_TYPE_PPTX_SLIDE_LAYOUT && !usage.has(partPath)) {
1073
1110
  unused.push(partPath);
1074
1111
  }
@@ -1082,8 +1119,17 @@ export async function analyzePptxLayouts(
1082
1119
  const layouts: AnalyzedLayout[] = [];
1083
1120
  const usedNames = new Set<string>();
1084
1121
  for (let i = 0; i < orderedLayoutPaths.length; i++) {
1122
+ await cancellationCheckpoint(i, options.signal);
1085
1123
  const layoutPath = orderedLayoutPaths[i]!;
1086
- const extracted = await extractLayout(pkg, layoutPath, sldSz, pxPerPt, hints, masterCache);
1124
+ const extracted = await extractLayout(
1125
+ pkg,
1126
+ layoutPath,
1127
+ sldSz,
1128
+ pxPerPt,
1129
+ hints,
1130
+ masterCache,
1131
+ options.signal,
1132
+ );
1087
1133
  if (!extracted) {
1088
1134
  warnings.push(`layout ${layoutPath} could not be read; its slides import plain`);
1089
1135
  continue;
@@ -1108,7 +1154,10 @@ export async function analyzePptxLayouts(
1108
1154
  const customs = layouts.filter((l) => l.verdict.kind === 'custom');
1109
1155
  if (customs.length > maxTemplates) {
1110
1156
  const ranked = [...customs].sort((a, b) => b.extracted.slideCount - a.extracted.slideCount);
1111
- for (const layout of ranked.slice(maxTemplates)) {
1157
+ const excess = ranked.slice(maxTemplates);
1158
+ for (let index = 0; index < excess.length; index++) {
1159
+ await cancellationCheckpoint(index, options.signal);
1160
+ const layout = excess[index]!;
1112
1161
  layout.verdict = { kind: 'plain' };
1113
1162
  layout.notes.push('dropped: more inferred layouts than the template cap');
1114
1163
  }
@@ -1118,6 +1167,7 @@ export async function analyzePptxLayouts(
1118
1167
  }
1119
1168
 
1120
1169
  const byLayoutPath = new Map(layouts.map((l) => [l.extracted.partPath, l]));
1170
+ throwIfOoxmlAborted(options.signal);
1121
1171
  return { slideSize: sldSz, layouts, byLayoutPath, layoutPathBySlide, warnings };
1122
1172
  }
1123
1173
 
@@ -1148,11 +1198,15 @@ export async function inspectPptxLayouts(
1148
1198
  data: ArrayBuffer | Blob,
1149
1199
  options: InspectPptxLayoutsOptions = {},
1150
1200
  ): Promise<{ layouts: PptxLayoutSummary[]; slideSize: { cx: number; cy: number } }> {
1201
+ throwIfOoxmlAborted(options.signal);
1151
1202
  const pkg = await openPackage(data, options);
1152
1203
  const analysis = await analyzePptxLayouts(pkg, options);
1153
- const layouts = analysis.layouts.map((l, i): PptxLayoutSummary => {
1204
+ const layouts: PptxLayoutSummary[] = [];
1205
+ for (let i = 0; i < analysis.layouts.length; i++) {
1206
+ await cancellationCheckpoint(i, options.signal);
1207
+ const l = analysis.layouts[i]!;
1154
1208
  const { extracted, verdict, notes } = l;
1155
- return {
1209
+ layouts.push({
1156
1210
  layoutPath: extracted.partPath,
1157
1211
  name: extracted.name || `Layout ${i + 1}`,
1158
1212
  ...(extracted.masterName ? { masterName: extracted.masterName } : {}),
@@ -1162,7 +1216,7 @@ export async function inspectPptxLayouts(
1162
1216
  ...(verdict.kind === 'builtin' ? { builtinTemplate: verdict.template } : {}),
1163
1217
  ...(verdict.kind === 'custom' ? { customTemplate: verdict.def } : {}),
1164
1218
  ...(notes.length > 0 ? { notes } : {}),
1165
- };
1166
- });
1219
+ });
1220
+ }
1167
1221
  return { layouts, slideSize: analysis.slideSize };
1168
1222
  }
@@ -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 {