@bendyline/squisq-formats 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +19 -0
  3. package/README.md +2 -2
  4. package/dist/{chunk-NNHKUXKA.js → chunk-26ISNJ7Y.js} +85 -65
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-KURGXM4I.js → chunk-4V3KCHAP.js} +3 -4
  7. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  8. package/dist/{chunk-EW54IRRS.js → chunk-6S6GU3ZG.js} +5 -6
  9. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  10. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  11. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  12. package/dist/{chunk-SC67HYQJ.js → chunk-EJTNGKEA.js} +5 -8
  13. package/dist/{chunk-WQSHGBLN.js → chunk-G6J326JW.js} +193 -42
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-GVS2XXV6.js → chunk-GYIVES2E.js} +485 -59
  16. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  17. package/dist/{chunk-RLU7UFYU.js → chunk-IPN56VLW.js} +83 -58
  18. package/dist/{chunk-DTDF6QDP.js → chunk-JE6LSIHE.js} +81 -20
  19. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  20. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  21. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  22. package/dist/chunk-OGS5VCGJ.js +446 -0
  23. package/dist/{chunk-74GO3FVS.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-QFLDYKCR.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-7ARKUCQT.js → chunk-X2DEAXNK.js} +54 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -11
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +10 -4
  61. package/dist/chunk-4VUWTSGM.js.map +0 -1
  62. package/dist/chunk-6M7Z25LA.js +0 -46
  63. package/dist/chunk-6M7Z25LA.js.map +0 -1
  64. package/dist/chunk-74GO3FVS.js.map +0 -1
  65. package/dist/chunk-7ARKUCQT.js.map +0 -1
  66. package/dist/chunk-DTDF6QDP.js.map +0 -1
  67. package/dist/chunk-EW54IRRS.js.map +0 -1
  68. package/dist/chunk-FE6OJV6O.js.map +0 -1
  69. package/dist/chunk-GVS2XXV6.js.map +0 -1
  70. package/dist/chunk-KURGXM4I.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-NNHKUXKA.js.map +0 -1
  74. package/dist/chunk-O3GVVND4.js.map +0 -1
  75. package/dist/chunk-ODL3SSPT.js.map +0 -1
  76. package/dist/chunk-PN52A5AA.js.map +0 -1
  77. package/dist/chunk-QFLDYKCR.js.map +0 -1
  78. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  79. package/dist/chunk-RLU7UFYU.js.map +0 -1
  80. package/dist/chunk-SC67HYQJ.js.map +0 -1
  81. package/dist/chunk-SSUPBUF5.js.map +0 -1
  82. package/dist/chunk-U4MRIFKL.js.map +0 -1
  83. package/dist/chunk-UGYF5AZE.js +0 -275
  84. package/dist/chunk-UGYF5AZE.js.map +0 -1
  85. package/dist/chunk-WQSHGBLN.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-OJ7ZQV6P.js +0 -15
  93. package/dist/extract-OJ7ZQV6P.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-5VDIRPIJ.js +0 -12
  100. package/dist/layouts-5VDIRPIJ.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -491
  111. package/src/__tests__/docxImport.test.ts +0 -531
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -439
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1375
  143. package/src/docx/import.ts +0 -1250
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -217
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
@@ -1,649 +0,0 @@
1
- /**
2
- * Tests for EPUB export: markdownDocToEpub, docToEpub.
3
- *
4
- * Verifies EPUB 3 structure, chapter splitting, XHTML content,
5
- * image embedding, metadata, and stylesheet generation.
6
- */
7
-
8
- import { afterEach, describe, it, expect, vi } from 'vitest';
9
- import JSZip from 'jszip';
10
- import type { MarkdownDocument, MarkdownBlockNode } from '@bendyline/squisq/markdown';
11
- import { markdownDocToEpub } from '../epub/export';
12
- import type { EpubExportOptions } from '../epub/export';
13
-
14
- // ============================================
15
- // Helpers
16
- // ============================================
17
-
18
- function makeDoc(
19
- children: MarkdownBlockNode[],
20
- frontmatter?: Record<string, unknown>,
21
- ): MarkdownDocument {
22
- return { type: 'document', children, frontmatter };
23
- }
24
-
25
- function heading(depth: 1 | 2 | 3, text: string): MarkdownBlockNode {
26
- return { type: 'heading', depth, children: [{ type: 'text', value: text }] };
27
- }
28
-
29
- function paragraph(text: string): MarkdownBlockNode {
30
- return { type: 'paragraph', children: [{ type: 'text', value: text }] };
31
- }
32
-
33
- function boldParagraph(text: string): MarkdownBlockNode {
34
- return {
35
- type: 'paragraph',
36
- children: [{ type: 'strong', children: [{ type: 'text', value: text }] }],
37
- };
38
- }
39
-
40
- function linkParagraph(text: string, url: string): MarkdownBlockNode {
41
- return {
42
- type: 'paragraph',
43
- children: [{ type: 'link', url, children: [{ type: 'text', value: text }] }],
44
- };
45
- }
46
-
47
- function imageParagraph(alt: string, url: string): MarkdownBlockNode {
48
- return {
49
- type: 'paragraph',
50
- children: [{ type: 'image', url, alt }],
51
- };
52
- }
53
-
54
- function codeBlock(code: string, lang?: string): MarkdownBlockNode {
55
- return { type: 'code', value: code, lang: lang ?? null };
56
- }
57
-
58
- async function exportAndUnzip(doc: MarkdownDocument, options?: EpubExportOptions): Promise<JSZip> {
59
- const buffer = await markdownDocToEpub(doc, options);
60
- return JSZip.loadAsync(buffer);
61
- }
62
-
63
- // ============================================
64
- // Tests
65
- // ============================================
66
-
67
- describe('markdownDocToEpub', () => {
68
- afterEach(() => {
69
- vi.unstubAllGlobals();
70
- });
71
-
72
- describe('EPUB structure', () => {
73
- it('produces a valid ZIP with required EPUB files', async () => {
74
- const doc = makeDoc([heading(1, 'Title'), paragraph('Content')]);
75
- const zip = await exportAndUnzip(doc);
76
-
77
- expect(zip.file('mimetype')).toBeTruthy();
78
- expect(zip.file('META-INF/container.xml')).toBeTruthy();
79
- expect(zip.file('OEBPS/content.opf')).toBeTruthy();
80
- expect(zip.file('OEBPS/toc.xhtml')).toBeTruthy();
81
- expect(zip.file('OEBPS/styles.css')).toBeTruthy();
82
- });
83
-
84
- it('mimetype contains "application/epub+zip"', async () => {
85
- const doc = makeDoc([heading(1, 'Test')]);
86
- const zip = await exportAndUnzip(doc);
87
- const mimetype = await zip.file('mimetype')!.async('string');
88
- expect(mimetype).toBe('application/epub+zip');
89
- });
90
-
91
- it('container.xml points to content.opf', async () => {
92
- const doc = makeDoc([heading(1, 'Test')]);
93
- const zip = await exportAndUnzip(doc);
94
- const containerXml = await zip.file('META-INF/container.xml')!.async('string');
95
- expect(containerXml).toContain('OEBPS/content.opf');
96
- });
97
- });
98
-
99
- describe('chapter splitting', () => {
100
- it('splits at H1 boundaries', async () => {
101
- const doc = makeDoc([
102
- heading(1, 'Chapter One'),
103
- paragraph('First chapter content'),
104
- heading(1, 'Chapter Two'),
105
- paragraph('Second chapter content'),
106
- ]);
107
- const zip = await exportAndUnzip(doc);
108
-
109
- expect(zip.file('OEBPS/chapters/chapter-001.xhtml')).toBeTruthy();
110
- expect(zip.file('OEBPS/chapters/chapter-002.xhtml')).toBeTruthy();
111
-
112
- const ch1 = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
113
- const ch2 = await zip.file('OEBPS/chapters/chapter-002.xhtml')!.async('string');
114
- expect(ch1).toContain('Chapter One');
115
- expect(ch1).toContain('First chapter content');
116
- expect(ch2).toContain('Chapter Two');
117
- expect(ch2).toContain('Second chapter content');
118
- });
119
-
120
- it('splits at H2 boundaries', async () => {
121
- const doc = makeDoc([
122
- heading(2, 'Section A'),
123
- paragraph('A content'),
124
- heading(2, 'Section B'),
125
- paragraph('B content'),
126
- ]);
127
- const zip = await exportAndUnzip(doc);
128
-
129
- expect(zip.file('OEBPS/chapters/chapter-001.xhtml')).toBeTruthy();
130
- expect(zip.file('OEBPS/chapters/chapter-002.xhtml')).toBeTruthy();
131
- });
132
-
133
- it('keeps content before first heading in a single chapter', async () => {
134
- const doc = makeDoc([
135
- paragraph('Intro text'),
136
- heading(1, 'Chapter One'),
137
- paragraph('Chapter content'),
138
- ]);
139
- const zip = await exportAndUnzip(doc);
140
-
141
- // Should have 2 chapters: intro + chapter one
142
- expect(zip.file('OEBPS/chapters/chapter-001.xhtml')).toBeTruthy();
143
- expect(zip.file('OEBPS/chapters/chapter-002.xhtml')).toBeTruthy();
144
-
145
- const ch1 = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
146
- expect(ch1).toContain('Intro text');
147
- });
148
-
149
- it('handles a document with no headings', async () => {
150
- const doc = makeDoc([paragraph('Just a paragraph')]);
151
- const zip = await exportAndUnzip(doc);
152
-
153
- expect(zip.file('OEBPS/chapters/chapter-001.xhtml')).toBeTruthy();
154
- const ch1 = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
155
- expect(ch1).toContain('Just a paragraph');
156
- });
157
- });
158
-
159
- describe('XHTML content', () => {
160
- it('renders headings with correct tags', async () => {
161
- const doc = makeDoc([heading(1, 'H1'), heading(3, 'H3'), paragraph('text')]);
162
- const zip = await exportAndUnzip(doc);
163
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
164
- expect(ch).toContain('<h1>H1</h1>');
165
- expect(ch).toContain('<h3>H3</h3>');
166
- });
167
-
168
- it('renders bold text as <strong>', async () => {
169
- const doc = makeDoc([heading(1, 'Test'), boldParagraph('important')]);
170
- const zip = await exportAndUnzip(doc);
171
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
172
- expect(ch).toContain('<strong>important</strong>');
173
- });
174
-
175
- it('renders links as <a> elements', async () => {
176
- const doc = makeDoc([heading(1, 'Test'), linkParagraph('click', 'https://example.com')]);
177
- const zip = await exportAndUnzip(doc);
178
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
179
- expect(ch).toContain('<a href="https://example.com">click</a>');
180
- });
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
-
191
- it('renders code blocks with language class', async () => {
192
- const doc = makeDoc([heading(1, 'Test'), codeBlock('const x = 1;', 'javascript')]);
193
- const zip = await exportAndUnzip(doc);
194
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
195
- expect(ch).toContain('class="language-javascript"');
196
- expect(ch).toContain('const x = 1;');
197
- });
198
-
199
- it('escapes special XML characters in text', async () => {
200
- const doc = makeDoc([heading(1, 'Test'), paragraph('a < b & c > d')]);
201
- const zip = await exportAndUnzip(doc);
202
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
203
- expect(ch).toContain('a &lt; b &amp; c &gt; d');
204
- });
205
-
206
- it('renders tables', async () => {
207
- const table: MarkdownBlockNode = {
208
- type: 'table',
209
- align: ['left', 'right'],
210
- children: [
211
- {
212
- type: 'tableRow',
213
- children: [
214
- { type: 'tableCell', isHeader: true, children: [{ type: 'text', value: 'Name' }] },
215
- { type: 'tableCell', isHeader: true, children: [{ type: 'text', value: 'Age' }] },
216
- ],
217
- },
218
- {
219
- type: 'tableRow',
220
- children: [
221
- { type: 'tableCell', children: [{ type: 'text', value: 'Alice' }] },
222
- { type: 'tableCell', children: [{ type: 'text', value: '30' }] },
223
- ],
224
- },
225
- ],
226
- };
227
- const doc = makeDoc([heading(1, 'Test'), table]);
228
- const zip = await exportAndUnzip(doc);
229
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
230
- expect(ch).toContain('<table>');
231
- expect(ch).toContain('<th');
232
- expect(ch).toContain('Name');
233
- expect(ch).toContain('<td');
234
- expect(ch).toContain('Alice');
235
- expect(ch).toContain('text-align: right');
236
- });
237
-
238
- it('renders thematic breaks as <hr/>', async () => {
239
- const doc = makeDoc([heading(1, 'Test'), { type: 'thematicBreak' }, paragraph('after')]);
240
- const zip = await exportAndUnzip(doc);
241
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
242
- expect(ch).toContain('<hr/>');
243
- });
244
-
245
- it('renders blockquotes', async () => {
246
- const doc = makeDoc([
247
- heading(1, 'Test'),
248
- { type: 'blockquote', children: [paragraph('A quote')] },
249
- ]);
250
- const zip = await exportAndUnzip(doc);
251
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
252
- expect(ch).toContain('<blockquote>');
253
- expect(ch).toContain('A quote');
254
- });
255
-
256
- it('renders ordered and unordered lists', async () => {
257
- const doc = makeDoc([
258
- heading(1, 'Test'),
259
- {
260
- type: 'list',
261
- ordered: false,
262
- children: [{ type: 'listItem', children: [paragraph('Bullet')] }],
263
- },
264
- {
265
- type: 'list',
266
- ordered: true,
267
- children: [{ type: 'listItem', children: [paragraph('Numbered')] }],
268
- },
269
- ]);
270
- const zip = await exportAndUnzip(doc);
271
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
272
- expect(ch).toContain('<ul>');
273
- expect(ch).toContain('<ol>');
274
- expect(ch).toContain('Bullet');
275
- expect(ch).toContain('Numbered');
276
- });
277
- });
278
-
279
- describe('images', () => {
280
- it('embeds provided images in the ZIP', async () => {
281
- const imgData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
282
- const doc = makeDoc([heading(1, 'Test'), imageParagraph('Logo', 'images/logo.png')]);
283
- const zip = await exportAndUnzip(doc, {
284
- images: new Map([['images/logo.png', imgData]]),
285
- });
286
-
287
- expect(zip.file('OEBPS/images/logo.png')).toBeTruthy();
288
- });
289
-
290
- it('references images with correct relative path in XHTML', async () => {
291
- const imgData = new Uint8Array([0xff, 0xd8]).buffer;
292
- const doc = makeDoc([heading(1, 'Test'), imageParagraph('Photo', 'photo.jpg')]);
293
- const zip = await exportAndUnzip(doc, {
294
- images: new Map([['photo.jpg', imgData]]),
295
- });
296
-
297
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
298
- expect(ch).toContain('src="../images/photo.jpg"');
299
- expect(ch).toContain('alt="Photo"');
300
- });
301
-
302
- it('lists images in the OPF manifest', async () => {
303
- const imgData = new Uint8Array([0x89]).buffer;
304
- const doc = makeDoc([heading(1, 'Test'), imageParagraph('Pic', 'pic.png')]);
305
- const zip = await exportAndUnzip(doc, {
306
- images: new Map([['pic.png', imgData]]),
307
- });
308
-
309
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
310
- expect(opf).toContain('images/pic.png');
311
- expect(opf).toContain('image/png');
312
- });
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
-
325
- it('embeds cover image with cover-image property', async () => {
326
- const coverData = new Uint8Array([0xff, 0xd8]).buffer;
327
- const doc = makeDoc([heading(1, 'Test')]);
328
- const zip = await exportAndUnzip(doc, { coverImage: coverData });
329
-
330
- expect(zip.file('OEBPS/images/cover.jpg')).toBeTruthy();
331
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
332
- expect(opf).toContain('cover-image');
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
- });
351
- });
352
-
353
- describe('metadata', () => {
354
- it('includes title in OPF', async () => {
355
- const doc = makeDoc([heading(1, 'Test')]);
356
- const zip = await exportAndUnzip(doc, { title: 'My Book' });
357
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
358
- expect(opf).toContain('<dc:title>My Book</dc:title>');
359
- });
360
-
361
- it('includes author in OPF', async () => {
362
- const doc = makeDoc([heading(1, 'Test')]);
363
- const zip = await exportAndUnzip(doc, { author: 'Jane Doe' });
364
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
365
- expect(opf).toContain('<dc:creator>Jane Doe</dc:creator>');
366
- });
367
-
368
- it('includes language in OPF', async () => {
369
- const doc = makeDoc([heading(1, 'Test')]);
370
- const zip = await exportAndUnzip(doc, { language: 'fr' });
371
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
372
- expect(opf).toContain('<dc:language>fr</dc:language>');
373
- });
374
-
375
- it('defaults language to "en"', async () => {
376
- const doc = makeDoc([heading(1, 'Test')]);
377
- const zip = await exportAndUnzip(doc);
378
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
379
- expect(opf).toContain('<dc:language>en</dc:language>');
380
- });
381
-
382
- it('includes description and publisher when provided', async () => {
383
- const doc = makeDoc([heading(1, 'Test')]);
384
- const zip = await exportAndUnzip(doc, {
385
- description: 'A great book',
386
- publisher: 'Acme Publishing',
387
- });
388
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
389
- expect(opf).toContain('<dc:description>A great book</dc:description>');
390
- expect(opf).toContain('<dc:publisher>Acme Publishing</dc:publisher>');
391
- });
392
-
393
- it('includes UUID identifier', async () => {
394
- const doc = makeDoc([heading(1, 'Test')]);
395
- const zip = await exportAndUnzip(doc);
396
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
397
- expect(opf).toContain('urn:uuid:');
398
- });
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
-
412
- it('uses frontmatter title as fallback', async () => {
413
- const doc = makeDoc([heading(1, 'Intro')], { title: 'Frontmatter Title' });
414
- const zip = await exportAndUnzip(doc);
415
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
416
- expect(opf).toContain('<dc:title>Frontmatter Title</dc:title>');
417
- });
418
- });
419
-
420
- describe('table of contents', () => {
421
- it('toc.xhtml lists all chapters', async () => {
422
- const doc = makeDoc([
423
- heading(1, 'Introduction'),
424
- paragraph('Intro content'),
425
- heading(1, 'Main Body'),
426
- paragraph('Body content'),
427
- ]);
428
- const zip = await exportAndUnzip(doc);
429
- const toc = await zip.file('OEBPS/toc.xhtml')!.async('string');
430
- expect(toc).toContain('Introduction');
431
- expect(toc).toContain('Main Body');
432
- expect(toc).toContain('epub:type="toc"');
433
- });
434
-
435
- it('spine references all chapters in order', async () => {
436
- const doc = makeDoc([heading(1, 'Ch 1'), heading(1, 'Ch 2'), heading(1, 'Ch 3')]);
437
- const zip = await exportAndUnzip(doc);
438
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
439
- expect(opf).toContain('idref="chapter-001"');
440
- expect(opf).toContain('idref="chapter-002"');
441
- expect(opf).toContain('idref="chapter-003"');
442
- });
443
- });
444
-
445
- describe('stylesheet', () => {
446
- it('includes base CSS styles', async () => {
447
- const doc = makeDoc([heading(1, 'Test')]);
448
- const zip = await exportAndUnzip(doc);
449
- const css = await zip.file('OEBPS/styles.css')!.async('string');
450
- expect(css).toContain('body');
451
- expect(css).toContain('font-family');
452
- expect(css).toContain('blockquote');
453
- });
454
-
455
- it('chapters reference the stylesheet', async () => {
456
- const doc = makeDoc([heading(1, 'Test')]);
457
- const zip = await exportAndUnzip(doc);
458
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
459
- expect(ch).toContain('href="../styles.css"');
460
- });
461
- });
462
-
463
- describe('empty document', () => {
464
- it('handles empty document gracefully', async () => {
465
- const doc = makeDoc([]);
466
- const zip = await exportAndUnzip(doc);
467
-
468
- // Should still produce valid EPUB structure
469
- expect(zip.file('mimetype')).toBeTruthy();
470
- expect(zip.file('OEBPS/content.opf')).toBeTruthy();
471
- expect(zip.file('OEBPS/chapters/chapter-001.xhtml')).toBeTruthy();
472
- });
473
- });
474
-
475
- describe('audio narration (Media Overlays)', () => {
476
- const audioData = new Uint8Array([0xff, 0xfb, 0x90, 0x00]).buffer; // fake MP3 header
477
- const audioSegments = [
478
- { src: 'audio/intro.mp3', name: 'intro', duration: 15, startTime: 0 },
479
- { src: 'audio/body.mp3', name: 'body', duration: 20, startTime: 15 },
480
- ];
481
-
482
- it('embeds audio files in the ZIP', async () => {
483
- const doc = makeDoc([
484
- heading(1, 'Intro'),
485
- paragraph('Hello'),
486
- heading(1, 'Body'),
487
- paragraph('World'),
488
- ]);
489
- const zip = await exportAndUnzip(doc, {
490
- audio: new Map([
491
- ['audio/intro.mp3', audioData],
492
- ['audio/body.mp3', audioData],
493
- ]),
494
- audioSegments,
495
- totalDuration: 35,
496
- });
497
-
498
- expect(zip.file('OEBPS/audio/intro.mp3')).toBeTruthy();
499
- expect(zip.file('OEBPS/audio/body.mp3')).toBeTruthy();
500
- });
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
-
550
- it('generates SMIL overlay files for each chapter', async () => {
551
- const doc = makeDoc([
552
- heading(1, 'Intro'),
553
- paragraph('Hello'),
554
- heading(1, 'Body'),
555
- paragraph('World'),
556
- ]);
557
- const zip = await exportAndUnzip(doc, {
558
- audio: new Map([
559
- ['audio/intro.mp3', audioData],
560
- ['audio/body.mp3', audioData],
561
- ]),
562
- audioSegments,
563
- totalDuration: 35,
564
- });
565
-
566
- expect(zip.file('OEBPS/chapters/chapter-001.smil')).toBeTruthy();
567
- expect(zip.file('OEBPS/chapters/chapter-002.smil')).toBeTruthy();
568
- });
569
-
570
- it('SMIL files reference correct chapter and audio', async () => {
571
- const doc = makeDoc([heading(1, 'Intro'), paragraph('Content')]);
572
- const zip = await exportAndUnzip(doc, {
573
- audio: new Map([['audio/intro.mp3', audioData]]),
574
- audioSegments: [audioSegments[0]],
575
- totalDuration: 15,
576
- });
577
-
578
- const smil = await zip.file('OEBPS/chapters/chapter-001.smil')!.async('string');
579
- expect(smil).toContain('chapter-001.xhtml');
580
- expect(smil).toContain('intro.mp3');
581
- expect(smil).toContain('clipBegin');
582
- expect(smil).toContain('clipEnd');
583
- expect(smil).toContain('<par');
584
- expect(smil).toContain('<text');
585
- expect(smil).toContain('<audio');
586
- });
587
-
588
- it('OPF includes media-overlay references', async () => {
589
- const doc = makeDoc([heading(1, 'Intro'), paragraph('Content')]);
590
- const zip = await exportAndUnzip(doc, {
591
- audio: new Map([['audio/intro.mp3', audioData]]),
592
- audioSegments: [audioSegments[0]],
593
- totalDuration: 15,
594
- });
595
-
596
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
597
- expect(opf).toContain('media-overlay=');
598
- expect(opf).toContain('application/smil+xml');
599
- expect(opf).toContain('audio/intro.mp3');
600
- });
601
-
602
- it('OPF includes total duration metadata', async () => {
603
- const doc = makeDoc([heading(1, 'Intro'), paragraph('Content')]);
604
- const zip = await exportAndUnzip(doc, {
605
- audio: new Map([['audio/intro.mp3', audioData]]),
606
- audioSegments: [audioSegments[0]],
607
- totalDuration: 35,
608
- });
609
-
610
- const opf = await zip.file('OEBPS/content.opf')!.async('string');
611
- expect(opf).toContain('media:duration');
612
- expect(opf).toContain('media:active-class');
613
- });
614
-
615
- it('chapter XHTML has element IDs for SMIL references', async () => {
616
- const doc = makeDoc([heading(1, 'Title'), paragraph('First'), paragraph('Second')]);
617
- const zip = await exportAndUnzip(doc, {
618
- audio: new Map([['audio/intro.mp3', audioData]]),
619
- audioSegments: [audioSegments[0]],
620
- totalDuration: 15,
621
- });
622
-
623
- const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
624
- expect(ch).toContain('id="p1"');
625
- expect(ch).toContain('id="p2"');
626
- expect(ch).toContain('id="p3"');
627
- });
628
-
629
- it('stylesheet includes media overlay active class', async () => {
630
- const doc = makeDoc([heading(1, 'Test')]);
631
- const zip = await exportAndUnzip(doc);
632
- const css = await zip.file('OEBPS/styles.css')!.async('string');
633
- expect(css).toContain('epub-media-overlay-active');
634
- });
635
-
636
- it('no SMIL files when audio is not provided', async () => {
637
- const doc = makeDoc([heading(1, 'Test'), paragraph('No audio')]);
638
- const zip = await exportAndUnzip(doc);
639
-
640
- // No .smil files should exist
641
- const smilFiles = Object.keys(zip.files).filter((f) => f.endsWith('.smil'));
642
- expect(smilFiles).toHaveLength(0);
643
-
644
- // No audio directory
645
- const audioDir = Object.keys(zip.files).filter((f) => f.startsWith('OEBPS/audio/'));
646
- expect(audioDir).toHaveLength(0);
647
- });
648
- });
649
- });