@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
@@ -5,9 +5,15 @@
5
5
  */
6
6
 
7
7
  import { describe, it, expect } from 'vitest';
8
+ import JSZip from 'jszip';
8
9
  import { parseMarkdown } from '@bendyline/squisq/markdown';
9
10
  import { markdownToDoc } from '@bendyline/squisq/doc';
10
- import { convert, ConversionError, defaultRegistry } from '../registry/index';
11
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
12
+ import { createTransformStyleRegistry, resolveTransformStyle } from '@bendyline/squisq/transform';
13
+ import { convert, ConversionError, defaultRegistry, prepareConversion } from '../registry/index';
14
+ import { zipToContainer } from '../container/index';
15
+ import { markdownDocToPptx } from '../pptx/export';
16
+ import { buildThemedPptx } from './pptxInferFixtures';
11
17
 
12
18
  const SAMPLE_MD = `# Round Trip
13
19
 
@@ -40,6 +46,105 @@ describe('convert error paths', () => {
40
46
  convert({ kind: 'markdown', markdown: '# hi' }, 'importonly', { registry }),
41
47
  ).rejects.toMatchObject({ name: 'ConversionError', code: 'unsupported-output' });
42
48
  });
49
+
50
+ it('normalizes importer failures to ConversionError', async () => {
51
+ const promise = convert({ kind: 'bytes', data: new TextEncoder().encode('not a zip') }, 'md', {
52
+ from: 'docx',
53
+ });
54
+ await expect(promise).rejects.toMatchObject({
55
+ name: 'ConversionError',
56
+ code: 'invalid-input',
57
+ format: 'docx',
58
+ });
59
+ });
60
+
61
+ it('threads cancellation to exporters and preserves the abort reason', async () => {
62
+ const controller = new AbortController();
63
+ const reason = new Error('caller cancelled conversion');
64
+ reason.name = 'AbortError';
65
+ const registry = defaultRegistry();
66
+ registry.register({
67
+ id: 'abortable',
68
+ label: 'Abortable',
69
+ mimeType: 'application/x-abortable',
70
+ extensions: ['.abortable'],
71
+ async exportDoc(_input, options) {
72
+ expect(options.signal).toBe(controller.signal);
73
+ controller.abort(reason);
74
+ throw new Error('must not replace the abort reason');
75
+ },
76
+ });
77
+
78
+ await expect(
79
+ convert({ kind: 'markdown', markdown: '# Cancel me' }, 'abortable', {
80
+ registry,
81
+ signal: controller.signal,
82
+ }),
83
+ ).rejects.toBe(reason);
84
+ });
85
+ });
86
+
87
+ describe('format option threading', () => {
88
+ it('threads CSV tableIndex and delimiter through the conversion facade', async () => {
89
+ const markdown =
90
+ '| A | B |\n|---|---|\n| first | row |\n\n| C | D |\n|---|---|\n| second | row |\n';
91
+ const result = await convert({ kind: 'markdown', markdown }, 'csv', {
92
+ formatOptions: { csv: { tableIndex: 1, delimiter: ';' } },
93
+ });
94
+ expect(new TextDecoder().decode(result.bytes)).toBe('C;D\r\nsecond;row');
95
+ expect(result.warnings).toEqual([]);
96
+ });
97
+
98
+ it('honors Markdown parse options for direct Markdown sources', async () => {
99
+ let captured: import('../registry/index').NormalizedInput | undefined;
100
+ const registry = defaultRegistry();
101
+ registry.register({
102
+ id: 'capture-md-options',
103
+ label: 'Capture Markdown Options',
104
+ mimeType: 'application/x-capture',
105
+ extensions: ['.capture'],
106
+ async exportDoc(input) {
107
+ captured = input;
108
+ return {
109
+ bytes: new Uint8Array(),
110
+ mimeType: 'application/x-capture',
111
+ suggestedFilename: '',
112
+ warnings: [],
113
+ };
114
+ },
115
+ });
116
+
117
+ await convert(
118
+ { kind: 'markdown', markdown: '---\ntitle: Hidden metadata\n---\n\n# Body' },
119
+ 'capture-md-options',
120
+ {
121
+ registry,
122
+ formatOptions: { md: { parse: { frontmatter: false } } },
123
+ },
124
+ );
125
+
126
+ expect(captured?.markdownDoc?.frontmatter).toBeUndefined();
127
+ });
128
+
129
+ it('threads archive safety limits into OOXML importers', async () => {
130
+ const docx = await convert({ kind: 'markdown', markdown: '# Limited' }, 'docx');
131
+ await expect(
132
+ convert({ kind: 'bytes', data: docx.bytes }, 'md', {
133
+ from: 'docx',
134
+ formatOptions: { docx: { maxEntries: 1 } },
135
+ }),
136
+ ).rejects.toMatchObject({ code: 'invalid-input', format: 'docx' });
137
+ });
138
+
139
+ it('can emit DBK with STORE so strict import compression limits accept its own output', async () => {
140
+ const result = await convert(
141
+ { kind: 'markdown', markdown: `# Self-compatible DBK\n\n${'x'.repeat(16 * 1024)}` },
142
+ 'dbk',
143
+ { formatOptions: { dbk: { compression: 'STORE' } } },
144
+ );
145
+ const restored = await zipToContainer(result.bytes, { maxCompressionRatio: 1.01 });
146
+ expect(await restored.readDocument()).toContain('Self-compatible DBK');
147
+ });
43
148
  });
44
149
 
45
150
  // ── round trip: md → docx → md ──────────────────────────────────────
@@ -94,6 +199,21 @@ describe('byte sniffing', () => {
94
199
  expect(text).toContain('In a container');
95
200
  });
96
201
 
202
+ it('DBK export snapshots the supplied source instead of stale container markdown', async () => {
203
+ const container = new MemoryContentContainer();
204
+ await container.writeDocument('# Old');
205
+ await container.writeFile('asset.bin', new Uint8Array([1, 2, 3]));
206
+
207
+ const result = await convert({ kind: 'markdown', markdown: '# New', container }, 'dbk');
208
+ const restored = await zipToContainer(result.bytes);
209
+ expect(await restored.readDocument()).toContain('# New');
210
+ expect(new Uint8Array((await restored.readFile('asset.bin'))!)).toEqual(
211
+ new Uint8Array([1, 2, 3]),
212
+ );
213
+ // Snapshotting must not mutate the caller's container.
214
+ expect(await container.readDocument()).toBe('# Old');
215
+ });
216
+
97
217
  it('disambiguates a docx zip via its content-types part', async () => {
98
218
  const docx = await convert({ kind: 'markdown', markdown: '# Word doc' }, 'docx');
99
219
  // No filename, no `from` — must read [Content_Types].xml to pick docx.
@@ -106,6 +226,113 @@ describe('byte sniffing', () => {
106
226
  // ── transform threading ─────────────────────────────────────────────
107
227
 
108
228
  describe('transform threading', () => {
229
+ it('normalizes and transforms once before exporting multiple targets', async () => {
230
+ let importCount = 0;
231
+ let transformLookupCount = 0;
232
+ const exportCounts = { first: 0, second: 0 };
233
+ const markers: string[] = [];
234
+ const exportSignals: Array<AbortSignal | undefined> = [];
235
+ const formats = defaultRegistry();
236
+ formats.register({
237
+ id: 'counted-input',
238
+ label: 'Counted input',
239
+ mimeType: 'application/x-counted-input',
240
+ extensions: ['.counted'],
241
+ async importDoc() {
242
+ importCount += 1;
243
+ return parseMarkdown(SAMPLE_MD);
244
+ },
245
+ });
246
+ for (const [id, key] of [
247
+ ['prepared-first', 'first'],
248
+ ['prepared-second', 'second'],
249
+ ] as const) {
250
+ formats.register({
251
+ id,
252
+ label: id,
253
+ mimeType: `application/x-${id}`,
254
+ extensions: [`.${key}`],
255
+ async exportDoc(input, options) {
256
+ exportCounts[key] += 1;
257
+ exportSignals.push(options.signal);
258
+ const targetOptions = options.formatOptions?.[id] as { marker?: string } | undefined;
259
+ markers.push(targetOptions?.marker ?? 'missing');
260
+ expect(input.doc.blocks.length).toBeGreaterThan(0);
261
+ return {
262
+ bytes: new TextEncoder().encode(key),
263
+ mimeType: `application/x-${id}`,
264
+ suggestedFilename: '',
265
+ warnings: [`${key} warning`],
266
+ };
267
+ },
268
+ });
269
+ }
270
+
271
+ const style = { ...resolveTransformStyle('magazine'), id: 'counted-transform' };
272
+ const backingTransforms = createTransformStyleRegistry([style]);
273
+ const transformRegistry = {
274
+ register: (candidate: typeof style) => backingTransforms.register(candidate),
275
+ unregister: (id: string) => backingTransforms.unregister(id),
276
+ get: (id: string) => {
277
+ transformLookupCount += 1;
278
+ return backingTransforms.get(id);
279
+ },
280
+ list: () => backingTransforms.list(),
281
+ };
282
+ const overall = new AbortController();
283
+ const prepared = await prepareConversion(
284
+ {
285
+ kind: 'bytes',
286
+ data: new TextEncoder().encode('count me once'),
287
+ filename: 'source.counted',
288
+ },
289
+ {
290
+ registry: formats,
291
+ from: 'counted-input',
292
+ transformStyle: 'counted-transform',
293
+ transformRegistry,
294
+ signal: overall.signal,
295
+ },
296
+ );
297
+ const first = await prepared.convert('prepared-first', {
298
+ formatOptions: { 'prepared-first': { marker: 'one' } },
299
+ });
300
+ const target = new AbortController();
301
+ const second = await prepared.convert('prepared-second', {
302
+ signal: target.signal,
303
+ formatOptions: { 'prepared-second': { marker: 'two' } },
304
+ });
305
+
306
+ expect(importCount).toBe(1);
307
+ expect(transformLookupCount).toBe(1);
308
+ expect(exportCounts).toEqual({ first: 1, second: 1 });
309
+ expect(markers).toEqual(['one', 'two']);
310
+ expect(exportSignals[0]).toBe(overall.signal);
311
+ expect(exportSignals[1]?.aborted).toBe(false);
312
+ expect(first.suggestedFilename).toBe('source.first');
313
+ expect(second.suggestedFilename).toBe('source.second');
314
+ expect(first.warnings).toContain('first warning');
315
+ expect(second.warnings).toContain('second warning');
316
+
317
+ await expect(prepared.convert('missing-target')).rejects.toMatchObject({
318
+ name: 'ConversionError',
319
+ code: 'unknown-format',
320
+ format: 'missing-target',
321
+ });
322
+ const targetReason = new Error('cancel target export');
323
+ target.abort(targetReason);
324
+ expect(exportSignals[1]?.reason).toBe(targetReason);
325
+ await expect(prepared.convert('prepared-first', { signal: target.signal })).rejects.toBe(
326
+ targetReason,
327
+ );
328
+ const overallReason = new Error('cancel prepared conversion');
329
+ overall.abort(overallReason);
330
+ await expect(
331
+ prepared.convert('prepared-first', { signal: new AbortController().signal }),
332
+ ).rejects.toBe(overallReason);
333
+ expect(exportCounts.first).toBe(1);
334
+ });
335
+
109
336
  it('applies a transform style before handing off to the exporter', async () => {
110
337
  // A capture format records the NormalizedInput it receives, letting us
111
338
  // observe that the transform ran without depending on any converter.
@@ -140,6 +367,40 @@ describe('transform threading', () => {
140
367
  // markdownDoc is re-derived from the transformed Doc.
141
368
  expect(captured!.markdownDoc).toBeDefined();
142
369
  });
370
+
371
+ it('resolves transform ids only from the explicitly supplied registry', async () => {
372
+ let captured: import('../registry/index').NormalizedInput | undefined;
373
+ const formats = defaultRegistry();
374
+ formats.register({
375
+ id: 'capture-custom-transform',
376
+ label: 'Capture Custom Transform',
377
+ mimeType: 'application/x-capture',
378
+ extensions: ['.cap'],
379
+ async exportDoc(input) {
380
+ captured = input;
381
+ return {
382
+ bytes: new Uint8Array([1]),
383
+ mimeType: 'application/x-capture',
384
+ suggestedFilename: '',
385
+ warnings: [],
386
+ };
387
+ },
388
+ });
389
+ const custom = {
390
+ ...resolveTransformStyle('minimal'),
391
+ id: 'tenant-transform',
392
+ suggestedThemeId: 'cinematic',
393
+ };
394
+ const transformRegistry = createTransformStyleRegistry([custom]);
395
+
396
+ await convert({ kind: 'markdown', markdown: SAMPLE_MD }, 'capture-custom-transform', {
397
+ registry: formats,
398
+ transformStyle: 'tenant-transform',
399
+ transformRegistry,
400
+ });
401
+
402
+ expect(captured?.doc.themeId).toBe('cinematic');
403
+ });
143
404
  });
144
405
 
145
406
  // ── doc-kind source ─────────────────────────────────────────────────
@@ -178,9 +439,57 @@ describe('suggestedFilename', () => {
178
439
  const out = await convert({ kind: 'markdown', markdown: '# t' }, 'md');
179
440
  expect(out.suggestedFilename).toBe('document.md');
180
441
  });
442
+
443
+ it('uses only the basename for Windows-style input paths', async () => {
444
+ const out = await convert(
445
+ {
446
+ kind: 'bytes',
447
+ data: new TextEncoder().encode('# Windows'),
448
+ filename: 'C:\\docs\\report.md',
449
+ },
450
+ 'md',
451
+ );
452
+ expect(out.suggestedFilename).toBe('report.md');
453
+ });
181
454
  });
182
455
 
183
456
  // sanity: ConversionError is exported and usable
457
+ // ── pptx theme/layout inference threading ───────────────────────────
458
+
459
+ describe('pptx import inference threading (default ON)', () => {
460
+ it('carries an inferred theme through convert() by default', async () => {
461
+ const deck = await markdownDocToPptx(parseMarkdown('# Hi\n\nBody.\n'), {});
462
+ const result = await convert({ kind: 'bytes', data: deck, filename: 'deck.pptx' }, 'md');
463
+ const md = new TextDecoder().decode(result.bytes);
464
+ expect(md).toContain('squisq-theme: custom-office-theme');
465
+ expect(md).toContain('squisq-custom-themes');
466
+ });
467
+
468
+ it('omits inference with formatOptions.pptx.inferTheme=false', async () => {
469
+ const deck = await markdownDocToPptx(parseMarkdown('# Hi\n\nBody.\n'), {});
470
+ const result = await convert({ kind: 'bytes', data: deck, filename: 'deck.pptx' }, 'md', {
471
+ formatOptions: { pptx: { inferTheme: false, inferLayouts: false } },
472
+ });
473
+ const md = new TextDecoder().decode(result.bytes);
474
+ expect(md).not.toContain('squisq-theme');
475
+ expect(md).not.toContain('squisq-custom-themes');
476
+ });
477
+
478
+ it('end-to-end: inferred theme colors reach a downstream docx export', async () => {
479
+ // Distinctive fixture theme (bg #fdfdf8, text #1a1a2e) → pptx → docx.
480
+ const result = await convert(
481
+ { kind: 'bytes', data: await buildThemedPptx(), filename: 'deck.pptx' },
482
+ 'docx',
483
+ );
484
+ const zip = await JSZip.loadAsync(result.bytes);
485
+ let text = '';
486
+ for (const name of Object.keys(zip.files)) {
487
+ if (/\.(xml|rels)$/i.test(name)) text += await zip.files[name]!.async('string');
488
+ }
489
+ expect(text.toLowerCase()).toContain('1a1a2e');
490
+ });
491
+ });
492
+
184
493
  it('exports ConversionError', () => {
185
494
  expect(new ConversionError('conversion-failed', 'x')).toBeInstanceOf(Error);
186
495
  });
@@ -5,7 +5,7 @@
5
5
  * image embedding, metadata, and stylesheet generation.
6
6
  */
7
7
 
8
- import { describe, it, expect } from 'vitest';
8
+ import { afterEach, describe, it, expect, vi } from 'vitest';
9
9
  import JSZip from 'jszip';
10
10
  import type { MarkdownDocument, MarkdownBlockNode } from '@bendyline/squisq/markdown';
11
11
  import { markdownDocToEpub } from '../epub/export';
@@ -65,6 +65,10 @@ async function exportAndUnzip(doc: MarkdownDocument, options?: EpubExportOptions
65
65
  // ============================================
66
66
 
67
67
  describe('markdownDocToEpub', () => {
68
+ afterEach(() => {
69
+ vi.unstubAllGlobals();
70
+ });
71
+
68
72
  describe('EPUB structure', () => {
69
73
  it('produces a valid ZIP with required EPUB files', async () => {
70
74
  const doc = makeDoc([heading(1, 'Title'), paragraph('Content')]);
@@ -175,6 +179,15 @@ describe('markdownDocToEpub', () => {
175
179
  expect(ch).toContain('<a href="https://example.com">click</a>');
176
180
  });
177
181
 
182
+ it('strips executable link schemes while preserving link text', async () => {
183
+ const doc = makeDoc([heading(1, 'Test'), linkParagraph('click', 'javascript:alert(1)')]);
184
+ const zip = await exportAndUnzip(doc);
185
+ const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
186
+ expect(ch).not.toContain('javascript:');
187
+ expect(ch).not.toContain('<a ');
188
+ expect(ch).toContain('click');
189
+ });
190
+
178
191
  it('renders code blocks with language class', async () => {
179
192
  const doc = makeDoc([heading(1, 'Test'), codeBlock('const x = 1;', 'javascript')]);
180
193
  const zip = await exportAndUnzip(doc);
@@ -298,6 +311,17 @@ describe('markdownDocToEpub', () => {
298
311
  expect(opf).toContain('image/png');
299
312
  });
300
313
 
314
+ it('flattens backslash-separated image paths to safe archive basenames', async () => {
315
+ const source = '..\\private\\logo.png';
316
+ const doc = makeDoc([heading(1, 'Test'), imageParagraph('Logo', source)]);
317
+ const zip = await exportAndUnzip(doc, {
318
+ images: new Map([[source, new Uint8Array([0x89]).buffer]]),
319
+ });
320
+
321
+ expect(zip.file('OEBPS/images/logo.png')).toBeTruthy();
322
+ expect(Object.keys(zip.files).some((path) => path.includes('\\'))).toBe(false);
323
+ });
324
+
301
325
  it('embeds cover image with cover-image property', async () => {
302
326
  const coverData = new Uint8Array([0xff, 0xd8]).buffer;
303
327
  const doc = makeDoc([heading(1, 'Test')]);
@@ -307,6 +331,23 @@ describe('markdownDocToEpub', () => {
307
331
  const opf = await zip.file('OEBPS/content.opf')!.async('string');
308
332
  expect(opf).toContain('cover-image');
309
333
  });
334
+
335
+ it('does not overwrite a document image whose basename collides with the cover', async () => {
336
+ const documentImage = new Uint8Array([1, 2, 3]).buffer;
337
+ const coverImage = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
338
+ const doc = makeDoc([heading(1, 'Test'), imageParagraph('Inline cover', 'cover.png')]);
339
+ const zip = await exportAndUnzip(doc, {
340
+ images: new Map([['cover.png', documentImage]]),
341
+ coverImage,
342
+ });
343
+
344
+ expect(await zip.file('OEBPS/images/cover.png')!.async('uint8array')).toEqual(
345
+ new Uint8Array(documentImage),
346
+ );
347
+ expect(zip.file('OEBPS/images/cover-2.png')).toBeTruthy();
348
+ const opf = await zip.file('OEBPS/content.opf')!.async('string');
349
+ expect(opf).toContain('href="images/cover-2.png"');
350
+ });
310
351
  });
311
352
 
312
353
  describe('metadata', () => {
@@ -356,6 +397,18 @@ describe('markdownDocToEpub', () => {
356
397
  expect(opf).toContain('urn:uuid:');
357
398
  });
358
399
 
400
+ it('falls back to getRandomValues when randomUUID is unavailable', async () => {
401
+ vi.stubGlobal('crypto', {
402
+ getRandomValues<T extends ArrayBufferView>(array: T): T {
403
+ new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(0xab);
404
+ return array;
405
+ },
406
+ });
407
+ const zip = await exportAndUnzip(makeDoc([heading(1, 'Test')]));
408
+ const opf = await zip.file('OEBPS/content.opf')!.async('string');
409
+ expect(opf).toMatch(/urn:uuid:abababab-abab-4bab-abab-abababababab/);
410
+ });
411
+
359
412
  it('uses frontmatter title as fallback', async () => {
360
413
  const doc = makeDoc([heading(1, 'Intro')], { title: 'Frontmatter Title' });
361
414
  const zip = await exportAndUnzip(doc);
@@ -446,6 +499,54 @@ describe('markdownDocToEpub', () => {
446
499
  expect(zip.file('OEBPS/audio/body.mp3')).toBeTruthy();
447
500
  });
448
501
 
502
+ it('deduplicates colliding audio basenames without overwriting either segment', async () => {
503
+ const doc = makeDoc([
504
+ heading(1, 'One'),
505
+ paragraph('First'),
506
+ heading(1, 'Two'),
507
+ paragraph('Second'),
508
+ ]);
509
+ const segments = [
510
+ { src: 'a/narration.mp3', name: 'one', duration: 10, startTime: 0 },
511
+ { src: 'b/narration.mp3', name: 'two', duration: 10, startTime: 10 },
512
+ ];
513
+ const zip = await exportAndUnzip(doc, {
514
+ audio: new Map([
515
+ ['a/narration.mp3', new Uint8Array([1]).buffer],
516
+ ['b/narration.mp3', new Uint8Array([2]).buffer],
517
+ ]),
518
+ audioSegments: segments,
519
+ totalDuration: 20,
520
+ });
521
+
522
+ expect(zip.file('OEBPS/audio/narration.mp3')).toBeTruthy();
523
+ expect(zip.file('OEBPS/audio/narration-2.mp3')).toBeTruthy();
524
+ const firstSmil = await zip.file('OEBPS/chapters/chapter-001.smil')!.async('string');
525
+ const secondSmil = await zip.file('OEBPS/chapters/chapter-002.smil')!.async('string');
526
+ expect(firstSmil).toContain('narration.mp3');
527
+ expect(secondSmil).toContain('narration-2.mp3');
528
+ });
529
+
530
+ it('writes one manifest item when multiple segments reuse one audio source', async () => {
531
+ const doc = makeDoc([
532
+ heading(1, 'One'),
533
+ paragraph('First'),
534
+ heading(1, 'Two'),
535
+ paragraph('Second'),
536
+ ]);
537
+ const zip = await exportAndUnzip(doc, {
538
+ audio: new Map([['narration.mp3', new Uint8Array([1, 2, 3]).buffer]]),
539
+ audioSegments: [
540
+ { src: 'narration.mp3', name: 'one', duration: 10, startTime: 0 },
541
+ { src: 'narration.mp3', name: 'two', duration: 10, startTime: 10 },
542
+ ],
543
+ totalDuration: 20,
544
+ });
545
+ const opf = await zip.file('OEBPS/content.opf')!.async('string');
546
+
547
+ expect(opf.match(/href="audio\/narration\.mp3"/g)).toHaveLength(1);
548
+ });
549
+
449
550
  it('generates SMIL overlay files for each chapter', async () => {
450
551
  const doc = makeDoc([
451
552
  heading(1, 'Intro'),
@@ -8,6 +8,7 @@
8
8
  import { describe, it, expect } from 'vitest';
9
9
  import JSZip from 'jszip';
10
10
  import type { Doc, Block, ImageLayer } from '@bendyline/squisq/schemas';
11
+ import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
11
12
  import { docToHtml, docToHtmlZip, collectImagePaths } from '../html/index';
12
13
  import { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from '../html/imageUtils';
13
14
 
@@ -95,6 +96,16 @@ describe('arrayBufferToBase64DataUrl', () => {
95
96
  expect(result).toMatch(/^data:text\/plain;base64,/);
96
97
  expect(result).toBe('data:text/plain;base64,aGVsbG8=');
97
98
  });
99
+
100
+ it('encodes large buffers correctly across chunk boundaries', () => {
101
+ const bytes = Uint8Array.from({ length: 100_003 }, (_, index) => index % 251);
102
+ const result = arrayBufferToBase64DataUrl(bytes.buffer, 'application/octet-stream');
103
+ const decoded = Uint8Array.from(atob(result.split(',')[1]), (character) =>
104
+ character.charCodeAt(0),
105
+ );
106
+
107
+ expect(decoded).toEqual(bytes);
108
+ });
98
109
  });
99
110
 
100
111
  describe('extractFilename', () => {
@@ -225,6 +236,41 @@ describe('docToHtml', () => {
225
236
  expect(html).toContain('test-doc');
226
237
  });
227
238
 
239
+ it('embeds an explicitly registered theme selected by the document', () => {
240
+ const external = compileTheme({
241
+ id: 'tenant-brand',
242
+ name: 'External Tenant Brand',
243
+ seedColors: { primary: '#6633cc' },
244
+ });
245
+ const html = docToHtml(makeDoc({ themeId: external.id }), {
246
+ playerScript: MOCK_PLAYER_SCRIPT,
247
+ themeRegistry: createThemeRegistry([external]),
248
+ });
249
+
250
+ expect(html).toContain('External Tenant Brand');
251
+ expect(html).toContain('\\"themeId\\":\\"tenant-brand\\"');
252
+ });
253
+
254
+ it('keeps a document-scoped theme ahead of the explicit registry', () => {
255
+ const inline = compileTheme({
256
+ id: 'tenant-brand',
257
+ name: 'Inline Tenant Brand',
258
+ seedColors: { primary: '#112233' },
259
+ });
260
+ const external = compileTheme({
261
+ id: 'tenant-brand',
262
+ name: 'External Tenant Brand',
263
+ seedColors: { primary: '#6633cc' },
264
+ });
265
+ const html = docToHtml(makeDoc({ themeId: inline.id, customThemes: [inline] }), {
266
+ playerScript: MOCK_PLAYER_SCRIPT,
267
+ themeRegistry: createThemeRegistry([external]),
268
+ });
269
+
270
+ expect(html).toContain('Inline Tenant Brand');
271
+ expect(html).not.toContain('External Tenant Brand');
272
+ });
273
+
228
274
  it('calls SquisqPlayer.mount', () => {
229
275
  const doc = makeDoc();
230
276
  const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
@@ -310,6 +356,22 @@ describe('docToHtmlZip', () => {
310
356
  expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
311
357
  });
312
358
 
359
+ it('embeds a caller-owned theme selected by the document', async () => {
360
+ const external = compileTheme({
361
+ id: 'tenant-zip-brand',
362
+ name: 'Tenant ZIP Brand',
363
+ seedColors: { primary: '#224466' },
364
+ });
365
+ const blob = await docToHtmlZip(makeDoc({ themeId: external.id }), {
366
+ playerScript: MOCK_PLAYER_SCRIPT,
367
+ themeRegistry: createThemeRegistry([external]),
368
+ });
369
+
370
+ const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
371
+ const html = await zip.file('index.html')!.async('text');
372
+ expect(html).toContain('Tenant ZIP Brand');
373
+ });
374
+
313
375
  it('preserves the original image path inside the zip', async () => {
314
376
  // Direct `<img src="folder/file.png">` references in the rendered HTML
315
377
  // resolve only if the zip mirrors the doc's path layout — flattening