@bendyline/squisq-formats 1.4.2 → 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 (140) hide show
  1. package/README.md +48 -2
  2. package/dist/chunk-2KPARF2P.js +99 -0
  3. package/dist/chunk-2KPARF2P.js.map +1 -0
  4. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  5. package/dist/chunk-4VUWTSGM.js.map +1 -0
  6. package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
  7. package/dist/chunk-ABVI556T.js.map +1 -0
  8. package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
  9. package/dist/chunk-CRAVSMPZ.js.map +1 -0
  10. package/dist/chunk-FE6OJV6O.js +560 -0
  11. package/dist/chunk-FE6OJV6O.js.map +1 -0
  12. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  13. package/dist/chunk-GVS2XXV6.js.map +1 -0
  14. package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
  15. package/dist/chunk-HTW2M27H.js.map +1 -0
  16. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  17. package/dist/chunk-LXYLOOST.js.map +1 -0
  18. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  19. package/dist/chunk-MLX2BOJC.js.map +1 -0
  20. package/dist/chunk-NKAJPJ4G.js +106 -0
  21. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  22. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  23. package/dist/chunk-ODL3SSPT.js +18 -0
  24. package/dist/chunk-ODL3SSPT.js.map +1 -0
  25. package/dist/chunk-QRVN6A6E.js +127 -0
  26. package/dist/chunk-QRVN6A6E.js.map +1 -0
  27. package/dist/chunk-U32AG3G3.js +195 -0
  28. package/dist/chunk-U32AG3G3.js.map +1 -0
  29. package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
  30. package/dist/chunk-VJJM2SSH.js.map +1 -0
  31. package/dist/chunk-VSYHZECT.js +778 -0
  32. package/dist/chunk-VSYHZECT.js.map +1 -0
  33. package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
  34. package/dist/chunk-WC7WULGV.js.map +1 -0
  35. package/dist/chunk-XKUMNGBW.js +131 -0
  36. package/dist/chunk-XKUMNGBW.js.map +1 -0
  37. package/dist/chunk-YRT7GQ5Y.js +28 -0
  38. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  39. package/dist/container/index.d.ts +13 -3
  40. package/dist/container/index.js +23 -20
  41. package/dist/container/index.js.map +1 -1
  42. package/dist/docx/index.d.ts +6 -2
  43. package/dist/docx/index.js +5 -3
  44. package/dist/epub/index.d.ts +3 -1
  45. package/dist/epub/index.js +3 -3
  46. package/dist/export-D2NkylDT.d.ts +69 -0
  47. package/dist/extract-H6RXJMHP.js +15 -0
  48. package/dist/extract-H6RXJMHP.js.map +1 -0
  49. package/dist/html/index.d.ts +15 -85
  50. package/dist/html/index.js +3 -3
  51. package/dist/images-7FBWPKE3.js +7 -0
  52. package/dist/images-7FBWPKE3.js.map +1 -0
  53. package/dist/import-DTkDxHmZ.d.ts +84 -0
  54. package/dist/import-K8mfc0fz.d.ts +136 -0
  55. package/dist/index.d.ts +14 -4
  56. package/dist/index.js +33 -12
  57. package/dist/infer/index.d.ts +138 -0
  58. package/dist/infer/index.js +28 -0
  59. package/dist/infer/index.js.map +1 -0
  60. package/dist/layouts-BHrgZ5FS.d.ts +150 -0
  61. package/dist/layouts-QVPK3ZCU.js +12 -0
  62. package/dist/layouts-QVPK3ZCU.js.map +1 -0
  63. package/dist/mapTheme-IR27S6IV.js +11 -0
  64. package/dist/mapTheme-IR27S6IV.js.map +1 -0
  65. package/dist/ooxml/index.d.ts +21 -127
  66. package/dist/ooxml/index.js +31 -9
  67. package/dist/pdf/index.d.ts +3 -1
  68. package/dist/pdf/index.js +1 -1
  69. package/dist/pptx/index.d.ts +8 -108
  70. package/dist/pptx/index.js +13 -4
  71. package/dist/reader-B9L8Ucbj.d.ts +140 -0
  72. package/dist/registry/index.d.ts +60 -8
  73. package/dist/registry/index.js +9 -4
  74. package/dist/themeReader-DJKErl_j.d.ts +56 -0
  75. package/dist/xlsx/index.d.ts +6 -66
  76. package/dist/xlsx/index.js +4 -2
  77. package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +310 -1
  81. package/src/__tests__/epub.test.ts +102 -1
  82. package/src/__tests__/html.test.ts +62 -0
  83. package/src/__tests__/inferTheme.test.ts +135 -0
  84. package/src/__tests__/lossyWarnings.test.ts +41 -0
  85. package/src/__tests__/ooxml.test.ts +76 -2
  86. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  87. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  88. package/src/__tests__/plainHtml.test.ts +15 -1
  89. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  90. package/src/__tests__/pptxImport.test.ts +30 -0
  91. package/src/__tests__/pptxInferFixtures.ts +314 -0
  92. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  93. package/src/__tests__/zipSafety.test.ts +317 -0
  94. package/src/container/index.ts +37 -32
  95. package/src/docx/export.ts +4 -2
  96. package/src/docx/import.ts +4 -3
  97. package/src/epub/export.ts +94 -25
  98. package/src/html/docsHtmlBundle.ts +6 -2
  99. package/src/html/htmlTemplate.ts +31 -6
  100. package/src/html/imageUtils.ts +14 -5
  101. package/src/html/index.ts +12 -1
  102. package/src/html/plainHtml.ts +7 -3
  103. package/src/html/plainHtmlBundle.ts +11 -9
  104. package/src/index.ts +23 -0
  105. package/src/infer/extract.ts +127 -0
  106. package/src/infer/index.ts +199 -0
  107. package/src/infer/mapTheme.ts +176 -0
  108. package/src/infer/types.ts +27 -0
  109. package/src/ooxml/index.ts +14 -0
  110. package/src/ooxml/readUtils.ts +44 -0
  111. package/src/ooxml/reader.ts +129 -29
  112. package/src/ooxml/themeReader.ts +197 -0
  113. package/src/ooxml/types.ts +8 -9
  114. package/src/ooxml/writer.ts +28 -10
  115. package/src/pdf/export.ts +4 -2
  116. package/src/pdf/import.ts +40 -34
  117. package/src/pptx/export.ts +4 -2
  118. package/src/pptx/import.ts +214 -32
  119. package/src/pptx/index.ts +14 -0
  120. package/src/pptx/layouts.ts +1222 -0
  121. package/src/registry/convert.ts +168 -53
  122. package/src/registry/defaultFormats.ts +135 -45
  123. package/src/registry/index.ts +6 -1
  124. package/src/registry/types.ts +69 -6
  125. package/src/shared/boundedZipArchive.ts +383 -0
  126. package/src/shared/fidelity.ts +130 -0
  127. package/src/shared/zipEntryCount.ts +151 -0
  128. package/src/shared/zipLimits.ts +296 -0
  129. package/src/shared/zipSafety.ts +19 -0
  130. package/src/xlsx/import.ts +3 -2
  131. package/dist/chunk-46FMDOWZ.js.map +0 -1
  132. package/dist/chunk-5LSSWZPU.js.map +0 -1
  133. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  134. package/dist/chunk-EHLGMSTE.js.map +0 -1
  135. package/dist/chunk-MJGRI6XR.js.map +0 -1
  136. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  137. package/dist/chunk-SHLPSADL.js.map +0 -1
  138. package/dist/chunk-WWBIKRNP.js.map +0 -1
  139. package/dist/chunk-YESS7VY3.js.map +0 -1
  140. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -9,9 +9,12 @@
9
9
 
10
10
  import type { MarkdownDocument } from '@bendyline/squisq/markdown';
11
11
  import type { ContentContainer } from '@bendyline/squisq/storage';
12
+ import type { TransformStyleInput, TransformStyleRegistry } from '@bendyline/squisq/transform';
12
13
  import { ConversionError } from './errors.js';
13
14
  import { defaultRegistry } from './registry.js';
15
+ import { openBoundedZipArchive, ZipSafetyError } from '../shared/zipSafety.js';
14
16
  import type {
17
+ BuiltinFormatOptions,
15
18
  ConversionResult,
16
19
  ConvertOptions,
17
20
  ConvertSource,
@@ -19,8 +22,22 @@ import type {
19
22
  FormatId,
20
23
  FormatRegistry,
21
24
  NormalizedInput,
25
+ PreparedConversion,
26
+ PreparedExportOptions,
22
27
  } from './types.js';
23
28
 
29
+ function markdownFormatOptions(options: ConvertOptions): BuiltinFormatOptions['md'] {
30
+ return (options.formatOptions?.md ?? {}) as BuiltinFormatOptions['md'];
31
+ }
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
+
24
41
  // ── Byte sniffing ───────────────────────────────────────────────────
25
42
 
26
43
  function hasPrefix(bytes: Uint8Array, prefix: number[]): boolean {
@@ -41,13 +58,19 @@ const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
41
58
  * marker means `dbk`.
42
59
  */
43
60
  async function sniffZip(bytes: Uint8Array): Promise<FormatId> {
44
- const JSZip = (await import('jszip')).default;
45
- const zip = await JSZip.loadAsync(bytes).catch(() => {
46
- throw new ConversionError('invalid-input', 'Input is not a readable ZIP archive.');
61
+ const archive = await openBoundedZipArchive(bytes).catch((cause: unknown) => {
62
+ if (cause instanceof ZipSafetyError && cause.code !== 'invalid-archive') throw cause;
63
+ throw new ConversionError('invalid-input', 'Input is not a readable ZIP archive.', { cause });
47
64
  });
48
- const contentTypes = zip.file('[Content_Types].xml');
65
+ const contentTypes = archive.entries.find((entry) => entry.path === '[Content_Types].xml');
49
66
  if (!contentTypes) return 'dbk';
50
- const xml = await contentTypes.async('string');
67
+ const maxContentTypesBytes = 1024 * 1024;
68
+ if ((contentTypes.declaredSize ?? 0) > maxContentTypesBytes) {
69
+ throw new ConversionError('invalid-input', 'OOXML content-types metadata is too large.');
70
+ }
71
+ const xmlBytes = await archive.read(contentTypes.path, maxContentTypesBytes);
72
+ if (!xmlBytes) return 'dbk';
73
+ const xml = new TextDecoder().decode(xmlBytes);
51
74
  if (xml.includes('wordprocessingml')) return 'docx';
52
75
  if (xml.includes('presentationml')) return 'pptx';
53
76
  if (xml.includes('spreadsheetml')) return 'xlsx';
@@ -80,7 +103,8 @@ async function detectByteFormat(
80
103
 
81
104
  /** Lowercased extension without the leading dot (`report.DocX` → `docx`). */
82
105
  function extractExt(filename: string): string {
83
- const base = filename.split('/').pop() ?? filename;
106
+ const normalized = filename.replace(/\\/g, '/');
107
+ const base = normalized.split('/').pop() ?? normalized;
84
108
  const dot = base.lastIndexOf('.');
85
109
  if (dot <= 0) return '';
86
110
  return base.slice(dot + 1).toLowerCase();
@@ -88,7 +112,8 @@ function extractExt(filename: string): string {
88
112
 
89
113
  /** Basename without its final extension (`a/b/report.docx` → `report`). */
90
114
  function baseNameOf(filename: string): string {
91
- const base = filename.split('/').pop() ?? filename;
115
+ const normalized = filename.replace(/\\/g, '/');
116
+ const base = normalized.split('/').pop() ?? normalized;
92
117
  const dot = base.lastIndexOf('.');
93
118
  return dot > 0 ? base.slice(0, dot) : base;
94
119
  }
@@ -147,7 +172,9 @@ async function normalizeBytes(
147
172
  );
148
173
  }
149
174
  const text = await container.readDocument();
150
- markdownDoc = text ? parseMarkdown(text) : { type: 'document', children: [] };
175
+ markdownDoc = text
176
+ ? parseMarkdown(text, markdownFormatOptions(options).parse)
177
+ : { type: 'document', children: [] };
151
178
  } else {
152
179
  // importDoc is guaranteed present by the guard above.
153
180
  markdownDoc = await fromDef.importDoc!(buffer, options);
@@ -176,7 +203,9 @@ async function normalizeMarkdown(
176
203
  const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
177
204
 
178
205
  const markdownDoc =
179
- typeof source.markdown === 'string' ? parseMarkdown(source.markdown) : source.markdown;
206
+ typeof source.markdown === 'string'
207
+ ? parseMarkdown(source.markdown, markdownFormatOptions(options).parse)
208
+ : source.markdown;
180
209
 
181
210
  let container = source.container;
182
211
  if (!container) {
@@ -228,13 +257,14 @@ async function normalize(
228
257
 
229
258
  async function applyTransformStyle(
230
259
  input: NormalizedInput,
231
- transformStyle: string,
260
+ transformStyle: TransformStyleInput,
232
261
  themeId?: string,
262
+ registry?: TransformStyleRegistry,
233
263
  ): Promise<string[]> {
234
264
  const { applyTransform, extractDocImages } = await import('@bendyline/squisq/transform');
235
265
  const { docToMarkdown, markdownToDoc } = await import('@bendyline/squisq/doc');
236
266
  const images = extractDocImages(input.doc.blocks);
237
- const result = applyTransform(input.doc, transformStyle, { themeId, images });
267
+ const result = applyTransform(input.doc, transformStyle, { themeId, images, registry });
238
268
  input.doc = result.doc;
239
269
  const markdownDoc = docToMarkdown(result.doc);
240
270
  input.markdownDoc = markdownDoc;
@@ -254,65 +284,150 @@ async function applyTransformStyle(
254
284
  return [];
255
285
  }
256
286
 
257
- // ── convert() ───────────────────────────────────────────────────────
287
+ // ── prepare / convert ───────────────────────────────────────────────
258
288
 
259
- /**
260
- * Convert a source document to a target format.
261
- *
262
- * @param source - A bytes / markdown / doc source.
263
- * @param to - The target format id (must be registered and support export).
264
- * @param options - Conversion options (registry, theme, transform, …).
265
- * @throws {@link ConversionError} on any failure, with a stable `code`.
266
- */
267
- export async function convert(
289
+ /** Normalize and transform a source once, then safely export that snapshot many times. */
290
+ export async function prepareConversion(
268
291
  source: ConvertSource,
269
- to: FormatId,
270
292
  options: ConvertOptions = {},
271
- ): Promise<ConversionResult> {
293
+ ): Promise<PreparedConversion> {
294
+ return prepareConversionInternal(source, options);
295
+ }
296
+
297
+ async function prepareConversionInternal(
298
+ source: ConvertSource,
299
+ options: ConvertOptions,
300
+ failureFormat?: FormatId,
301
+ ): Promise<PreparedConversion> {
302
+ throwIfAborted(options.signal);
272
303
  const registry = options.registry ?? defaultRegistry();
273
304
 
274
- const target: FormatDefinition | undefined = registry.get(to);
275
- if (!target) {
276
- throw new ConversionError('unknown-format', `Unknown target format "${to}".`, { format: to });
277
- }
278
- if (!target.exportDoc) {
279
- throw new ConversionError(
280
- 'unsupported-output',
281
- `Format "${target.label}" does not support export.`,
282
- { format: to, hint: 'This format is import-only.' },
283
- );
305
+ let normalized: Normalized;
306
+ try {
307
+ normalized = await normalize(source, options, registry);
308
+ throwIfAborted(options.signal);
309
+ } catch (err: unknown) {
310
+ throwIfAborted(options.signal);
311
+ if (err instanceof ConversionError) throw err;
312
+ const message = err instanceof Error ? err.message : String(err);
313
+ throw new ConversionError('invalid-input', message, {
314
+ format: options.from,
315
+ cause: err,
316
+ });
284
317
  }
285
-
286
- const { input, warnings } = await normalize(source, options, registry);
318
+ const { input, warnings } = normalized;
287
319
 
288
320
  // Respect an existing doc theme; only fill it in from options when absent.
289
321
  if (options.themeId && !input.doc.themeId) {
290
- input.doc.themeId = options.themeId;
291
- }
292
-
293
- if (options.transformStyle) {
294
- warnings.push(
295
- ...(await applyTransformStyle(
296
- input,
297
- options.transformStyle,
298
- options.themeId ?? input.doc.themeId,
299
- )),
300
- );
322
+ input.doc = { ...input.doc, themeId: options.themeId };
301
323
  }
302
324
 
303
- let result: ConversionResult;
304
325
  try {
305
- result = await target.exportDoc(input, options);
326
+ if (options.transformStyle) {
327
+ warnings.push(
328
+ ...(await applyTransformStyle(
329
+ input,
330
+ options.transformStyle,
331
+ options.themeId ?? input.doc.themeId,
332
+ options.transformRegistry,
333
+ )),
334
+ );
335
+ throwIfAborted(options.signal);
336
+ }
306
337
  } catch (err: unknown) {
338
+ throwIfAborted(options.signal);
307
339
  if (err instanceof ConversionError) throw err;
308
340
  const message = err instanceof Error ? err.message : String(err);
309
- throw new ConversionError('conversion-failed', message, { format: to, cause: err });
341
+ throw new ConversionError('conversion-failed', message, {
342
+ format: failureFormat,
343
+ cause: err,
344
+ });
310
345
  }
311
346
 
312
- 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 {
313
394
  return {
314
- ...result,
315
- suggestedFilename: `${input.baseName}.${ext}`,
316
- 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 },
317
403
  };
318
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
+ }
@@ -11,8 +11,10 @@
11
11
  import type { ContentContainer } from '@bendyline/squisq/storage';
12
12
  import type { MarkdownDocument } from '@bendyline/squisq/markdown';
13
13
  import { ConversionError } from './errors.js';
14
+ import { markdownFidelityWarnings } from '../shared/fidelity.js';
14
15
  import type {
15
16
  ConversionResult,
17
+ BuiltinFormatOptions,
16
18
  ConvertOptions,
17
19
  FormatDefinition,
18
20
  NormalizedInput,
@@ -57,6 +59,13 @@ function resolveThemeId(input: NormalizedInput, options: ConvertOptions): string
57
59
  return options.themeId ?? input.doc.themeId;
58
60
  }
59
61
 
62
+ function optionsFor<K extends keyof BuiltinFormatOptions>(
63
+ options: ConvertOptions,
64
+ id: K,
65
+ ): BuiltinFormatOptions[K] {
66
+ return (options.formatOptions?.[id] ?? {}) as BuiltinFormatOptions[K];
67
+ }
68
+
60
69
  /** The markdown shape an exporter needs — reuse the source's, else derive it. */
61
70
  async function markdownOf(input: NormalizedInput): Promise<MarkdownDocument> {
62
71
  if (input.markdownDoc) return input.markdownDoc;
@@ -85,6 +94,34 @@ async function collectContainerImages(
85
94
  return images;
86
95
  }
87
96
 
97
+ async function collectContainerDocxImages(
98
+ container: ContentContainer,
99
+ ): Promise<Map<string, { data: ArrayBuffer; contentType: string }>> {
100
+ const images = await collectContainerImages(container);
101
+ const { extToMime } = await import('../shared/images.js');
102
+ return new Map(
103
+ [...images].map(([path, data]) => [
104
+ path,
105
+ { data, contentType: extToMime(path.split('.').pop() ?? '') },
106
+ ]),
107
+ );
108
+ }
109
+
110
+ /** Copy a container and make its primary document match the normalized source. */
111
+ async function snapshotContainerWithDocument(input: NormalizedInput): Promise<ContentContainer> {
112
+ const { MemoryContentContainer } = await import('@bendyline/squisq/storage');
113
+ const snapshot = new MemoryContentContainer();
114
+ const entries = await input.container.listFiles();
115
+ for (const entry of entries) {
116
+ const data = await input.container.readFile(entry.path);
117
+ if (data) await snapshot.writeFile(entry.path, new Uint8Array(data), entry.mimeType);
118
+ }
119
+ const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
120
+ const documentPath = (await input.container.getDocumentPath()) ?? 'index.md';
121
+ await snapshot.writeDocument(stringifyMarkdown(await markdownOf(input)), documentPath);
122
+ return snapshot;
123
+ }
124
+
88
125
  /** Resolve the player IIFE bundle or throw a helpful missing-dependency error. */
89
126
  async function requirePlayerScript(options: ConvertOptions, format: string): Promise<string> {
90
127
  const script = await options.resolvePlayerScript?.();
@@ -102,6 +139,20 @@ function ok(bytes: Uint8Array, mimeType: string, warnings: string[] = []): Conve
102
139
  return { bytes, mimeType, suggestedFilename: '', warnings };
103
140
  }
104
141
 
142
+ /**
143
+ * Narrow the untyped per-format escape hatch (`options.formatOptions.pptx`)
144
+ * into the PPTX importer's options. Theme + layout inference default ON;
145
+ * only an explicit `false` disables them.
146
+ */
147
+ function pptxImportOptionsFrom(options: ConvertOptions): BuiltinFormatOptions['pptx'] {
148
+ const raw = optionsFor(options, 'pptx');
149
+ return {
150
+ ...raw,
151
+ inferTheme: raw?.inferTheme !== false,
152
+ inferLayouts: raw?.inferLayouts !== false,
153
+ };
154
+ }
155
+
105
156
  // ── Definitions ─────────────────────────────────────────────────────
106
157
 
107
158
  export function defaultFormats(): FormatDefinition[] {
@@ -110,13 +161,13 @@ export function defaultFormats(): FormatDefinition[] {
110
161
  label: 'Markdown',
111
162
  mimeType: MIME.md,
112
163
  extensions: ['.md', '.markdown'],
113
- async importDoc(data): Promise<MarkdownDocument> {
164
+ async importDoc(data, options): Promise<MarkdownDocument> {
114
165
  const { parseMarkdown } = await import('@bendyline/squisq/markdown');
115
- return parseMarkdown(new TextDecoder().decode(data));
166
+ return parseMarkdown(new TextDecoder().decode(data), optionsFor(options, 'md').parse);
116
167
  },
117
- async exportDoc(input): Promise<ConversionResult> {
168
+ async exportDoc(input, options): Promise<ConversionResult> {
118
169
  const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
119
- const text = stringifyMarkdown(await markdownOf(input));
170
+ const text = stringifyMarkdown(await markdownOf(input), optionsFor(options, 'md').stringify);
120
171
  return ok(new TextEncoder().encode(text), MIME.md);
121
172
  },
122
173
  };
@@ -126,20 +177,28 @@ export function defaultFormats(): FormatDefinition[] {
126
177
  label: 'Word (DOCX)',
127
178
  mimeType: MIME.docx,
128
179
  extensions: ['.docx'],
129
- async importContainer(data): Promise<ContentContainer> {
180
+ async importContainer(data, options): Promise<ContentContainer> {
130
181
  const { docxToContainer } = await import('../docx/index.js');
131
- return docxToContainer(data);
182
+ return docxToContainer(data, optionsFor(options, 'docx'));
132
183
  },
133
- async importDoc(data): Promise<MarkdownDocument> {
184
+ async importDoc(data, options): Promise<MarkdownDocument> {
134
185
  const { docxToMarkdownDoc } = await import('../docx/index.js');
135
- return docxToMarkdownDoc(data);
186
+ return docxToMarkdownDoc(data, optionsFor(options, 'docx'));
136
187
  },
137
188
  async exportDoc(input, options): Promise<ConversionResult> {
138
189
  const { markdownDocToDocx } = await import('../docx/index.js');
139
- const buf = await markdownDocToDocx(await markdownOf(input), {
190
+ const raw = optionsFor(options, 'docx');
191
+ const markdownDoc = await markdownOf(input);
192
+ const containerImages = await collectContainerDocxImages(input.container);
193
+ const images = new Map([...containerImages, ...(raw.images ?? new Map())]);
194
+ const buf = await markdownDocToDocx(markdownDoc, {
195
+ ...raw,
196
+ ...(options.title !== undefined ? { title: options.title } : {}),
140
197
  themeId: resolveThemeId(input, options),
198
+ themeRegistry: options.themeRegistry ?? raw.themeRegistry,
199
+ images,
141
200
  });
142
- return ok(await toBytes(buf), MIME.docx);
201
+ return ok(await toBytes(buf), MIME.docx, markdownFidelityWarnings(markdownDoc, 'docx'));
143
202
  },
144
203
  };
145
204
 
@@ -148,20 +207,25 @@ export function defaultFormats(): FormatDefinition[] {
148
207
  label: 'PDF',
149
208
  mimeType: MIME.pdf,
150
209
  extensions: ['.pdf'],
151
- async importContainer(data): Promise<ContentContainer> {
210
+ async importContainer(data, options): Promise<ContentContainer> {
152
211
  const { pdfToContainer } = await import('../pdf/index.js');
153
- return pdfToContainer(data);
212
+ return pdfToContainer(data, optionsFor(options, 'pdf'));
154
213
  },
155
- async importDoc(data): Promise<MarkdownDocument> {
214
+ async importDoc(data, options): Promise<MarkdownDocument> {
156
215
  const { pdfToMarkdownDoc } = await import('../pdf/index.js');
157
- return pdfToMarkdownDoc(data);
216
+ return pdfToMarkdownDoc(data, optionsFor(options, 'pdf'));
158
217
  },
159
218
  async exportDoc(input, options): Promise<ConversionResult> {
160
219
  const { markdownDocToPdf } = await import('../pdf/index.js');
161
- const buf = await markdownDocToPdf(await markdownOf(input), {
220
+ const raw = optionsFor(options, 'pdf');
221
+ const markdownDoc = await markdownOf(input);
222
+ const buf = await markdownDocToPdf(markdownDoc, {
223
+ ...raw,
224
+ ...(options.title !== undefined ? { title: options.title } : {}),
162
225
  themeId: resolveThemeId(input, options),
226
+ themeRegistry: options.themeRegistry ?? raw.themeRegistry,
163
227
  });
164
- return ok(await toBytes(buf), MIME.pdf);
228
+ return ok(await toBytes(buf), MIME.pdf, markdownFidelityWarnings(markdownDoc, 'pdf'));
165
229
  },
166
230
  };
167
231
 
@@ -170,22 +234,28 @@ export function defaultFormats(): FormatDefinition[] {
170
234
  label: 'PowerPoint (PPTX)',
171
235
  mimeType: MIME.pptx,
172
236
  extensions: ['.pptx'],
173
- async importContainer(data): Promise<ContentContainer> {
237
+ async importContainer(data, options): Promise<ContentContainer> {
174
238
  const { pptxToContainer } = await import('../pptx/index.js');
175
- return pptxToContainer(data);
239
+ return pptxToContainer(data, pptxImportOptionsFrom(options));
176
240
  },
177
- async importDoc(data): Promise<MarkdownDocument> {
241
+ async importDoc(data, options): Promise<MarkdownDocument> {
178
242
  const { pptxToMarkdownDoc } = await import('../pptx/index.js');
179
- return pptxToMarkdownDoc(data);
243
+ return pptxToMarkdownDoc(data, pptxImportOptionsFrom(options));
180
244
  },
181
245
  async exportDoc(input, options): Promise<ConversionResult> {
182
246
  const { markdownDocToPptx } = await import('../pptx/index.js');
183
- const images = await collectContainerImages(input.container);
184
- const buf = await markdownDocToPptx(await markdownOf(input), {
247
+ const raw = optionsFor(options, 'pptx');
248
+ const markdownDoc = await markdownOf(input);
249
+ const containerImages = await collectContainerImages(input.container);
250
+ const images = new Map([...containerImages, ...(raw.images ?? new Map())]);
251
+ const buf = await markdownDocToPptx(markdownDoc, {
252
+ ...raw,
253
+ ...(options.title !== undefined ? { title: options.title } : {}),
185
254
  themeId: resolveThemeId(input, options),
255
+ themeRegistry: options.themeRegistry ?? raw.themeRegistry,
186
256
  images,
187
257
  });
188
- return ok(await toBytes(buf), MIME.pptx);
258
+ return ok(await toBytes(buf), MIME.pptx, markdownFidelityWarnings(markdownDoc, 'pptx'));
189
259
  },
190
260
  };
191
261
 
@@ -194,11 +264,11 @@ export function defaultFormats(): FormatDefinition[] {
194
264
  label: 'Excel (XLSX)',
195
265
  mimeType: MIME.xlsx,
196
266
  extensions: ['.xlsx'],
197
- async importDoc(data): Promise<MarkdownDocument> {
267
+ async importDoc(data, options): Promise<MarkdownDocument> {
198
268
  const { xlsxToMarkdownDoc } = await import('../xlsx/index.js');
199
- return xlsxToMarkdownDoc(data);
269
+ return xlsxToMarkdownDoc(data, optionsFor(options, 'xlsx'));
200
270
  },
201
- async exportDoc(input): Promise<ConversionResult> {
271
+ async exportDoc(input, options): Promise<ConversionResult> {
202
272
  const { markdownDocToXlsx } = await import('../xlsx/index.js');
203
273
  const markdownDoc = await markdownOf(input);
204
274
  // XLSX fidelity is tables-only: `table` nodes become worksheets and
@@ -211,7 +281,10 @@ export function defaultFormats(): FormatDefinition[] {
211
281
  omitted > 0
212
282
  ? [`XLSX export is tables-only; ${omitted} non-table block(s) were omitted.`]
213
283
  : [];
214
- const blob = await markdownDocToXlsx(markdownDoc);
284
+ const blob = await markdownDocToXlsx(markdownDoc, {
285
+ ...optionsFor(options, 'xlsx'),
286
+ ...(options.title !== undefined ? { title: options.title } : {}),
287
+ });
215
288
  return ok(await toBytes(blob), MIME.xlsx, warnings);
216
289
  },
217
290
  };
@@ -221,21 +294,22 @@ export function defaultFormats(): FormatDefinition[] {
221
294
  label: 'CSV',
222
295
  mimeType: MIME.csv,
223
296
  extensions: ['.csv'],
224
- async importDoc(data): Promise<MarkdownDocument> {
297
+ async importDoc(data, options): Promise<MarkdownDocument> {
225
298
  const { csvToMarkdownDoc } = await import('../csv/index.js');
226
- return csvToMarkdownDoc(data);
299
+ return csvToMarkdownDoc(data, optionsFor(options, 'csv'));
227
300
  },
228
- async exportDoc(input): Promise<ConversionResult> {
301
+ async exportDoc(input, options): Promise<ConversionResult> {
229
302
  const { markdownDocToCsv } = await import('../csv/index.js');
230
303
  const markdownDoc = await markdownOf(input);
304
+ const raw = optionsFor(options, 'csv');
231
305
  const tableCount = markdownDoc.children.filter((n) => n.type === 'table').length;
232
306
  const warnings: string[] = [];
233
- if (tableCount > 1) {
307
+ if (tableCount > 1 && raw.tableIndex === undefined) {
234
308
  warnings.push(
235
309
  `Document has ${tableCount} tables; CSV export emitted only the first. Use the csv converter's tableIndex option to select another.`,
236
310
  );
237
311
  }
238
- const text = markdownDocToCsv(markdownDoc);
312
+ const text = markdownDocToCsv(markdownDoc, raw);
239
313
  return ok(new TextEncoder().encode(text), MIME.csv, warnings);
240
314
  },
241
315
  };
@@ -245,22 +319,26 @@ export function defaultFormats(): FormatDefinition[] {
245
319
  label: 'HTML (single file)',
246
320
  mimeType: MIME.html,
247
321
  extensions: ['.html', '.htm'],
248
- async importDoc(data): Promise<MarkdownDocument> {
322
+ async importDoc(data, options): Promise<MarkdownDocument> {
249
323
  const { htmlToMarkdownDoc } = await import('../html/index.js');
250
- return htmlToMarkdownDoc(data);
324
+ return htmlToMarkdownDoc(data, optionsFor(options, 'html'));
251
325
  },
252
326
  // importContainer omitted: HTML import already inlines images as data URIs,
253
327
  // so there is nothing to extract into a container this wave.
254
328
  async exportDoc(input, options): Promise<ConversionResult> {
255
329
  const playerScript = await requirePlayerScript(options, 'html');
256
330
  const { docToHtml } = await import('../html/index.js');
257
- const images = await collectContainerImages(input.container);
331
+ const raw = optionsFor(options, 'html');
332
+ const containerImages = await collectContainerImages(input.container);
333
+ const images = new Map([...containerImages, ...(raw.images ?? new Map())]);
258
334
  const htmlText = docToHtml(input.doc, {
335
+ ...raw,
259
336
  playerScript,
260
337
  images,
261
338
  title: options.title ?? input.baseName,
262
- mode: 'static',
339
+ mode: raw.mode ?? 'static',
263
340
  themeId: resolveThemeId(input, options),
341
+ themeRegistry: options.themeRegistry ?? raw.themeRegistry,
264
342
  });
265
343
  return ok(new TextEncoder().encode(htmlText), MIME.html);
266
344
  },
@@ -274,13 +352,17 @@ export function defaultFormats(): FormatDefinition[] {
274
352
  async exportDoc(input, options): Promise<ConversionResult> {
275
353
  const playerScript = await requirePlayerScript(options, 'htmlzip');
276
354
  const { docToHtmlZip } = await import('../html/index.js');
277
- const images = await collectContainerImages(input.container);
355
+ const raw = optionsFor(options, 'htmlzip');
356
+ const containerImages = await collectContainerImages(input.container);
357
+ const images = new Map([...containerImages, ...(raw.images ?? new Map())]);
278
358
  const blob = await docToHtmlZip(input.doc, {
359
+ ...raw,
279
360
  playerScript,
280
361
  images,
281
362
  title: options.title ?? input.baseName,
282
- mode: 'static',
363
+ mode: raw.mode ?? 'static',
283
364
  themeId: resolveThemeId(input, options),
365
+ themeRegistry: options.themeRegistry ?? raw.themeRegistry,
284
366
  });
285
367
  return ok(await toBytes(blob), MIME.zip);
286
368
  },
@@ -293,13 +375,18 @@ export function defaultFormats(): FormatDefinition[] {
293
375
  extensions: ['.epub'],
294
376
  async exportDoc(input, options): Promise<ConversionResult> {
295
377
  const { markdownDocToEpub } = await import('../epub/index.js');
296
- const images = await collectContainerImages(input.container);
297
- const buf = await markdownDocToEpub(await markdownOf(input), {
378
+ const raw = optionsFor(options, 'epub');
379
+ const markdownDoc = await markdownOf(input);
380
+ const containerImages = await collectContainerImages(input.container);
381
+ const images = new Map([...containerImages, ...(raw.images ?? new Map())]);
382
+ const buf = await markdownDocToEpub(markdownDoc, {
383
+ ...raw,
298
384
  title: options.title ?? input.baseName,
299
385
  themeId: resolveThemeId(input, options),
386
+ themeRegistry: options.themeRegistry ?? raw.themeRegistry,
300
387
  images,
301
388
  });
302
- return ok(await toBytes(buf), MIME.epub);
389
+ return ok(await toBytes(buf), MIME.epub, markdownFidelityWarnings(markdownDoc, 'epub'));
303
390
  },
304
391
  };
305
392
 
@@ -308,13 +395,16 @@ export function defaultFormats(): FormatDefinition[] {
308
395
  label: 'Squisq container (DBK)',
309
396
  mimeType: MIME.zip,
310
397
  extensions: ['.dbk', '.zip'],
311
- async importContainer(data): Promise<ContentContainer> {
398
+ async importContainer(data, options): Promise<ContentContainer> {
312
399
  const { zipToContainer } = await import('../container/index.js');
313
- return zipToContainer(data);
400
+ return zipToContainer(data, optionsFor(options, 'dbk'));
314
401
  },
315
- async exportDoc(input): Promise<ConversionResult> {
402
+ async exportDoc(input, options): Promise<ConversionResult> {
316
403
  const { containerToZip } = await import('../container/index.js');
317
- const blob = await containerToZip(input.container);
404
+ const blob = await containerToZip(
405
+ await snapshotContainerWithDocument(input),
406
+ optionsFor(options, 'dbk'),
407
+ );
318
408
  return ok(await toBytes(blob), MIME.zip);
319
409
  },
320
410
  };
@@ -27,6 +27,11 @@ export type {
27
27
  FormatDefinition,
28
28
  FormatRegistry,
29
29
  ConvertSource,
30
+ BuiltinFormatOptions,
31
+ MarkdownFormatOptions,
32
+ DbkFormatOptions,
33
+ PreparedConversion,
34
+ PreparedExportOptions,
30
35
  } from './types.js';
31
36
  export { BUILTIN_FORMAT_IDS } from './types.js';
32
37
 
@@ -35,4 +40,4 @@ export type { ConversionErrorCode, ConversionErrorOptions } from './errors.js';
35
40
 
36
41
  export { createRegistry, defaultRegistry } from './registry.js';
37
42
  export { defaultFormats } from './defaultFormats.js';
38
- export { convert } from './convert.js';
43
+ export { convert, prepareConversion } from './convert.js';