@bendyline/squisq-formats 1.3.0 → 1.3.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 (161) hide show
  1. package/dist/container/index.d.ts +7 -5
  2. package/dist/container/index.js +39 -70
  3. package/dist/container/index.js.map +1 -1
  4. package/dist/docx/index.d.ts +128 -15
  5. package/dist/docx/index.js +17 -22
  6. package/dist/docx/index.js.map +1 -1
  7. package/dist/epub/index.d.ts +66 -11
  8. package/dist/epub/index.js +10 -18
  9. package/dist/epub/index.js.map +1 -1
  10. package/dist/html/index.d.ts +278 -15
  11. package/dist/html/index.js +26 -165
  12. package/dist/html/index.js.map +1 -1
  13. package/dist/index.d.ts +9 -38
  14. package/dist/index.js +62 -37
  15. package/dist/index.js.map +1 -1
  16. package/dist/ooxml/index.d.ts +353 -14
  17. package/dist/ooxml/index.js +116 -22
  18. package/dist/ooxml/index.js.map +1 -1
  19. package/dist/pdf/index.d.ts +143 -17
  20. package/dist/pdf/index.js +16 -25
  21. package/dist/pdf/index.js.map +1 -1
  22. package/dist/pptx/index.d.ts +83 -8
  23. package/dist/pptx/index.js +14 -34
  24. package/dist/pptx/index.js.map +1 -1
  25. package/dist/xlsx/index.d.ts +12 -9
  26. package/dist/xlsx/index.js +12 -44
  27. package/dist/xlsx/index.js.map +1 -1
  28. package/package.json +2 -2
  29. package/dist/__tests__/container.test.d.ts +0 -8
  30. package/dist/__tests__/container.test.d.ts.map +0 -1
  31. package/dist/__tests__/container.test.js +0 -147
  32. package/dist/__tests__/container.test.js.map +0 -1
  33. package/dist/__tests__/docxExport.test.d.ts +0 -8
  34. package/dist/__tests__/docxExport.test.d.ts.map +0 -1
  35. package/dist/__tests__/docxExport.test.js +0 -383
  36. package/dist/__tests__/docxExport.test.js.map +0 -1
  37. package/dist/__tests__/docxImport.test.d.ts +0 -8
  38. package/dist/__tests__/docxImport.test.d.ts.map +0 -1
  39. package/dist/__tests__/docxImport.test.js +0 -309
  40. package/dist/__tests__/docxImport.test.js.map +0 -1
  41. package/dist/__tests__/epub.test.d.ts +0 -8
  42. package/dist/__tests__/epub.test.d.ts.map +0 -1
  43. package/dist/__tests__/epub.test.js +0 -472
  44. package/dist/__tests__/epub.test.js.map +0 -1
  45. package/dist/__tests__/html.test.d.ts +0 -8
  46. package/dist/__tests__/html.test.d.ts.map +0 -1
  47. package/dist/__tests__/html.test.js +0 -317
  48. package/dist/__tests__/html.test.js.map +0 -1
  49. package/dist/__tests__/ooxml.test.d.ts +0 -5
  50. package/dist/__tests__/ooxml.test.d.ts.map +0 -1
  51. package/dist/__tests__/ooxml.test.js +0 -152
  52. package/dist/__tests__/ooxml.test.js.map +0 -1
  53. package/dist/__tests__/pdfExport.test.d.ts +0 -8
  54. package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
  55. package/dist/__tests__/pdfExport.test.js +0 -275
  56. package/dist/__tests__/pdfExport.test.js.map +0 -1
  57. package/dist/__tests__/pdfImport.test.d.ts +0 -11
  58. package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
  59. package/dist/__tests__/pdfImport.test.js +0 -254
  60. package/dist/__tests__/pdfImport.test.js.map +0 -1
  61. package/dist/__tests__/plainHtml.test.d.ts +0 -5
  62. package/dist/__tests__/plainHtml.test.d.ts.map +0 -1
  63. package/dist/__tests__/plainHtml.test.js +0 -327
  64. package/dist/__tests__/plainHtml.test.js.map +0 -1
  65. package/dist/__tests__/plainHtmlBundle.test.d.ts +0 -11
  66. package/dist/__tests__/plainHtmlBundle.test.d.ts.map +0 -1
  67. package/dist/__tests__/plainHtmlBundle.test.js +0 -210
  68. package/dist/__tests__/plainHtmlBundle.test.js.map +0 -1
  69. package/dist/__tests__/roundTrip.test.d.ts +0 -7
  70. package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
  71. package/dist/__tests__/roundTrip.test.js +0 -173
  72. package/dist/__tests__/roundTrip.test.js.map +0 -1
  73. package/dist/container/index.d.ts.map +0 -1
  74. package/dist/docx/export.d.ts +0 -69
  75. package/dist/docx/export.d.ts.map +0 -1
  76. package/dist/docx/export.js +0 -985
  77. package/dist/docx/export.js.map +0 -1
  78. package/dist/docx/import.d.ts +0 -65
  79. package/dist/docx/import.d.ts.map +0 -1
  80. package/dist/docx/import.js +0 -779
  81. package/dist/docx/import.js.map +0 -1
  82. package/dist/docx/index.d.ts.map +0 -1
  83. package/dist/docx/styles.d.ts +0 -68
  84. package/dist/docx/styles.d.ts.map +0 -1
  85. package/dist/docx/styles.js +0 -125
  86. package/dist/docx/styles.js.map +0 -1
  87. package/dist/epub/export.d.ts +0 -72
  88. package/dist/epub/export.d.ts.map +0 -1
  89. package/dist/epub/export.js +0 -698
  90. package/dist/epub/export.js.map +0 -1
  91. package/dist/epub/index.d.ts.map +0 -1
  92. package/dist/html/docsHtmlBundle.d.ts +0 -53
  93. package/dist/html/docsHtmlBundle.d.ts.map +0 -1
  94. package/dist/html/docsHtmlBundle.js +0 -299
  95. package/dist/html/docsHtmlBundle.js.map +0 -1
  96. package/dist/html/htmlTemplate.d.ts +0 -70
  97. package/dist/html/htmlTemplate.d.ts.map +0 -1
  98. package/dist/html/htmlTemplate.js +0 -282
  99. package/dist/html/htmlTemplate.js.map +0 -1
  100. package/dist/html/imageUtils.d.ts +0 -29
  101. package/dist/html/imageUtils.d.ts.map +0 -1
  102. package/dist/html/imageUtils.js +0 -63
  103. package/dist/html/imageUtils.js.map +0 -1
  104. package/dist/html/index.d.ts.map +0 -1
  105. package/dist/html/plainHtml.d.ts +0 -79
  106. package/dist/html/plainHtml.d.ts.map +0 -1
  107. package/dist/html/plainHtml.js +0 -614
  108. package/dist/html/plainHtml.js.map +0 -1
  109. package/dist/html/plainHtmlBundle.d.ts +0 -93
  110. package/dist/html/plainHtmlBundle.d.ts.map +0 -1
  111. package/dist/html/plainHtmlBundle.js +0 -357
  112. package/dist/html/plainHtmlBundle.js.map +0 -1
  113. package/dist/index.d.ts.map +0 -1
  114. package/dist/ooxml/index.d.ts.map +0 -1
  115. package/dist/ooxml/namespaces.d.ts +0 -77
  116. package/dist/ooxml/namespaces.d.ts.map +0 -1
  117. package/dist/ooxml/namespaces.js +0 -104
  118. package/dist/ooxml/namespaces.js.map +0 -1
  119. package/dist/ooxml/reader.d.ts +0 -52
  120. package/dist/ooxml/reader.d.ts.map +0 -1
  121. package/dist/ooxml/reader.js +0 -190
  122. package/dist/ooxml/reader.js.map +0 -1
  123. package/dist/ooxml/types.d.ts +0 -82
  124. package/dist/ooxml/types.d.ts.map +0 -1
  125. package/dist/ooxml/types.js +0 -9
  126. package/dist/ooxml/types.js.map +0 -1
  127. package/dist/ooxml/writer.d.ts +0 -71
  128. package/dist/ooxml/writer.d.ts.map +0 -1
  129. package/dist/ooxml/writer.js +0 -200
  130. package/dist/ooxml/writer.js.map +0 -1
  131. package/dist/ooxml/xmlUtils.d.ts +0 -76
  132. package/dist/ooxml/xmlUtils.d.ts.map +0 -1
  133. package/dist/ooxml/xmlUtils.js +0 -108
  134. package/dist/ooxml/xmlUtils.js.map +0 -1
  135. package/dist/pdf/export.d.ts +0 -56
  136. package/dist/pdf/export.d.ts.map +0 -1
  137. package/dist/pdf/export.js +0 -768
  138. package/dist/pdf/export.js.map +0 -1
  139. package/dist/pdf/import.d.ts +0 -94
  140. package/dist/pdf/import.d.ts.map +0 -1
  141. package/dist/pdf/import.js +0 -796
  142. package/dist/pdf/import.js.map +0 -1
  143. package/dist/pdf/index.d.ts.map +0 -1
  144. package/dist/pdf/styles.d.ts +0 -142
  145. package/dist/pdf/styles.d.ts.map +0 -1
  146. package/dist/pdf/styles.js +0 -145
  147. package/dist/pdf/styles.js.map +0 -1
  148. package/dist/pptx/export.d.ts +0 -75
  149. package/dist/pptx/export.d.ts.map +0 -1
  150. package/dist/pptx/export.js +0 -724
  151. package/dist/pptx/export.js.map +0 -1
  152. package/dist/pptx/index.d.ts.map +0 -1
  153. package/dist/pptx/styles.d.ts +0 -54
  154. package/dist/pptx/styles.d.ts.map +0 -1
  155. package/dist/pptx/styles.js +0 -73
  156. package/dist/pptx/styles.js.map +0 -1
  157. package/dist/pptx/templates.d.ts +0 -24
  158. package/dist/pptx/templates.d.ts.map +0 -1
  159. package/dist/pptx/templates.js +0 -158
  160. package/dist/pptx/templates.js.map +0 -1
  161. package/dist/xlsx/index.d.ts.map +0 -1
@@ -1,698 +0,0 @@
1
- /**
2
- * EPUB 3 Export
3
- *
4
- * Converts a MarkdownDocument (or Doc) to an EPUB 3 file (.epub).
5
- *
6
- * An EPUB is a ZIP archive containing XHTML chapter files, images,
7
- * a package manifest (content.opf), and a navigation document (toc.xhtml).
8
- * Content is split into chapters at H1/H2 heading boundaries.
9
- *
10
- * Uses JSZip for packaging (already a dependency), escapeXml from the
11
- * OOXML utils, and image utilities from the HTML exporter.
12
- *
13
- * @example
14
- * ```ts
15
- * import { markdownDocToEpub } from '@bendyline/squisq-formats/epub';
16
- *
17
- * const epub = await markdownDocToEpub(markdownDoc, {
18
- * title: 'My Book',
19
- * author: 'Jane Doe',
20
- * });
21
- * ```
22
- */
23
- import JSZip from 'jszip';
24
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
25
- import { escapeXml } from '../ooxml/xmlUtils.js';
26
- import { inferMimeType, extractFilename } from '../html/imageUtils.js';
27
- /**
28
- * Convert a MarkdownDocument to an EPUB 3 file.
29
- *
30
- * Chapters are split at H1/H2 heading boundaries. All referenced images
31
- * (provided via `options.images`) are embedded in the archive.
32
- */
33
- export async function markdownDocToEpub(doc, options = {}) {
34
- const fmTitle = doc.frontmatter?.title;
35
- const fmAuthor = doc.frontmatter?.author;
36
- const title = options.title ?? (typeof fmTitle === 'string' ? fmTitle : 'Untitled');
37
- const author = options.author ?? (typeof fmAuthor === 'string' ? fmAuthor : '');
38
- const language = options.language ?? 'en';
39
- const description = options.description ?? '';
40
- const publisher = options.publisher ?? '';
41
- const uuid = crypto.randomUUID();
42
- // Split document into chapters
43
- const chapters = splitIntoChapters(doc.children);
44
- // Collect images referenced in the document, deduplicating filenames
45
- const imageEntries = collectDocImages(doc.children);
46
- const resolvedImages = new Map();
47
- if (options.images) {
48
- const usedNames = new Set();
49
- for (const src of imageEntries) {
50
- const data = options.images.get(src);
51
- if (data) {
52
- let filename = extractFilename(src);
53
- // Deduplicate: if two paths share a basename (e.g. a/hero.png and b/hero.png)
54
- if (usedNames.has(filename)) {
55
- const dot = filename.lastIndexOf('.');
56
- const base = dot > 0 ? filename.slice(0, dot) : filename;
57
- const ext = dot > 0 ? filename.slice(dot) : '';
58
- let counter = 2;
59
- while (usedNames.has(`${base}-${counter}${ext}`))
60
- counter++;
61
- filename = `${base}-${counter}${ext}`;
62
- }
63
- usedNames.add(filename);
64
- resolvedImages.set(src, { data, mime: inferMimeType(filename), filename });
65
- }
66
- }
67
- }
68
- // Generate theme CSS
69
- const css = generateStylesheet(options.themeId);
70
- // Build the ZIP
71
- const zip = new JSZip();
72
- // mimetype must be first entry, stored (not compressed)
73
- zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
74
- // META-INF/container.xml
75
- zip.file('META-INF/container.xml', CONTAINER_XML);
76
- // OEBPS/styles.css
77
- zip.file('OEBPS/styles.css', css);
78
- // OEBPS/images/*
79
- for (const [, img] of resolvedImages) {
80
- zip.file(`OEBPS/images/${img.filename}`, img.data);
81
- }
82
- // Cover image — detect PNG vs JPEG from magic bytes.
83
- // When provided, generates a cover.xhtml page in the spine so e-readers
84
- // (especially Kindle) display the cover as both thumbnail and first page.
85
- let coverFilename;
86
- if (options.coverImage) {
87
- const bytes = new Uint8Array(options.coverImage);
88
- const isPng = bytes.length >= 4 &&
89
- bytes[0] === 0x89 &&
90
- bytes[1] === 0x50 &&
91
- bytes[2] === 0x4e &&
92
- bytes[3] === 0x47;
93
- coverFilename = isPng ? 'cover.png' : 'cover.jpg';
94
- zip.file(`OEBPS/images/${coverFilename}`, options.coverImage);
95
- // Generate cover XHTML page — full-bleed image, no margins
96
- const coverXhtml = `<?xml version="1.0" encoding="UTF-8"?>
97
- <!DOCTYPE html>
98
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
99
- <head>
100
- <meta charset="UTF-8"/>
101
- <title>Cover</title>
102
- <style>
103
- body { margin: 0; padding: 0; text-align: center; }
104
- img { max-width: 100%; max-height: 100%; }
105
- </style>
106
- </head>
107
- <body>
108
- <img src="../images/${escapeXml(coverFilename)}" alt="Cover"/>
109
- </body>
110
- </html>`;
111
- zip.file('OEBPS/chapters/cover.xhtml', coverXhtml);
112
- }
113
- // ── Audio narration ──────────────────────────────────────────────
114
- const audioMap = options.audio;
115
- const audioSegments = options.audioSegments;
116
- const hasAudio = audioMap && audioSegments && audioSegments.length > 0;
117
- // Per-segment audio file info, indexed by segment index (null if data missing)
118
- const segmentAudioFiles = [];
119
- const allAudioFiles = [];
120
- if (hasAudio) {
121
- for (const seg of audioSegments) {
122
- const data = audioMap.get(seg.src) ?? audioMap.get(seg.name);
123
- if (data) {
124
- const filename = extractFilename(seg.src);
125
- const finalName = filename.includes('.') ? filename : `${filename}.mp3`;
126
- zip.file(`OEBPS/audio/${finalName}`, data);
127
- const info = { filename: finalName, mime: inferMimeType(finalName) };
128
- segmentAudioFiles.push(info);
129
- allAudioFiles.push(info);
130
- }
131
- else {
132
- segmentAudioFiles.push(null);
133
- }
134
- }
135
- }
136
- // Build chapter-to-audio mapping for SMIL overlays
137
- const chapterAudio = [];
138
- if (hasAudio && allAudioFiles.length > 0) {
139
- if (chapters.length !== audioSegments.length) {
140
- console.warn(`EPUB: ${chapters.length} chapters but ${audioSegments.length} audio segments — ` +
141
- `extra chapters will reuse the last segment's audio`);
142
- }
143
- for (let i = 0; i < chapters.length; i++) {
144
- const segIdx = Math.min(i, audioSegments.length - 1);
145
- const seg = audioSegments[segIdx];
146
- const audioFile = segmentAudioFiles[segIdx];
147
- if (audioFile) {
148
- chapterAudio.push({
149
- audioFilename: audioFile.filename,
150
- clipStart: 0,
151
- clipEnd: seg.duration,
152
- duration: seg.duration,
153
- });
154
- }
155
- else {
156
- chapterAudio.push(null);
157
- }
158
- }
159
- }
160
- // OEBPS/chapters/*.xhtml + optional SMIL overlays
161
- const chapterFiles = [];
162
- for (let i = 0; i < chapters.length; i++) {
163
- const chap = chapters[i];
164
- const num = String(i + 1).padStart(3, '0');
165
- const id = `chapter-${num}`;
166
- const filename = `${id}.xhtml`;
167
- const audioInfo = chapterAudio[i] ?? null;
168
- // Render XHTML with element IDs for SMIL references when audio is present
169
- const xhtml = renderChapterXhtml(chap.nodes, title, resolvedImages, audioInfo !== null);
170
- zip.file(`OEBPS/chapters/${filename}`, xhtml);
171
- let smilFilename;
172
- if (audioInfo) {
173
- smilFilename = `${id}.smil`;
174
- const smil = generateSmil(filename, audioInfo, chap.nodes);
175
- zip.file(`OEBPS/chapters/${smilFilename}`, smil);
176
- }
177
- chapterFiles.push({
178
- id,
179
- filename,
180
- title: chap.title,
181
- smilFilename,
182
- duration: audioInfo?.duration,
183
- });
184
- }
185
- // OEBPS/toc.xhtml (EPUB 3 nav)
186
- zip.file('OEBPS/toc.xhtml', generateTocXhtml(chapterFiles, title));
187
- // OEBPS/content.opf
188
- zip.file('OEBPS/content.opf', generateContentOpf({
189
- uuid,
190
- title,
191
- author,
192
- language,
193
- description,
194
- publisher,
195
- chapters: chapterFiles,
196
- images: resolvedImages,
197
- coverFilename,
198
- audioFiles: allAudioFiles,
199
- totalDuration: options.totalDuration,
200
- }));
201
- const blob = await zip.generateAsync({
202
- type: 'arraybuffer',
203
- compression: 'DEFLATE',
204
- compressionOptions: { level: 6 },
205
- // mimetype was already set to STORE above; JSZip respects per-file options
206
- });
207
- return blob;
208
- }
209
- /**
210
- * Convert a squisq Doc to an EPUB 3 file.
211
- *
212
- * Convenience wrapper: Doc → MarkdownDocument → EPUB.
213
- * When the Doc has audio segments and `options.audio` is provided,
214
- * EPUB 3 Media Overlays are generated for narrated playback.
215
- */
216
- export async function docToEpub(doc, options = {}) {
217
- const { docToMarkdown } = await import('@bendyline/squisq/doc');
218
- const markdownDoc = docToMarkdown(doc);
219
- // Thread audio segment metadata from the Doc into options
220
- const epubOptions = { ...options };
221
- if (doc.audio?.segments?.length && !epubOptions.audioSegments) {
222
- epubOptions.audioSegments = doc.audio.segments;
223
- }
224
- if (doc.duration && !epubOptions.totalDuration) {
225
- epubOptions.totalDuration = doc.duration;
226
- }
227
- return markdownDocToEpub(markdownDoc, epubOptions);
228
- }
229
- function splitIntoChapters(nodes) {
230
- const chapters = [];
231
- let currentNodes = [];
232
- let currentTitle = 'Untitled';
233
- for (const node of nodes) {
234
- if (node.type === 'heading' && node.depth <= 2) {
235
- // Flush previous chapter
236
- if (currentNodes.length > 0) {
237
- chapters.push({ title: currentTitle, nodes: currentNodes });
238
- }
239
- currentTitle = extractHeadingText(node);
240
- currentNodes = [node];
241
- }
242
- else {
243
- currentNodes.push(node);
244
- }
245
- }
246
- // Flush remaining
247
- if (currentNodes.length > 0) {
248
- chapters.push({ title: currentTitle, nodes: currentNodes });
249
- }
250
- // If no chapters were created, wrap everything as one
251
- if (chapters.length === 0) {
252
- chapters.push({ title: 'Untitled', nodes: [] });
253
- }
254
- return chapters;
255
- }
256
- function extractHeadingText(heading) {
257
- return heading.children.map(inlineToText).join('');
258
- }
259
- function inlineToText(node) {
260
- switch (node.type) {
261
- case 'text':
262
- return node.value;
263
- case 'emphasis':
264
- case 'strong':
265
- case 'delete':
266
- return node.children.map(inlineToText).join('');
267
- case 'inlineCode':
268
- return node.value;
269
- case 'link':
270
- return node.children.map(inlineToText).join('');
271
- case 'image':
272
- return node.alt ?? '';
273
- case 'break':
274
- return ' ';
275
- default:
276
- return '';
277
- }
278
- }
279
- // ── Image Collection ──────────────────────────────────────────────
280
- function collectDocImages(nodes) {
281
- const images = new Set();
282
- function walkBlock(node) {
283
- switch (node.type) {
284
- case 'paragraph':
285
- case 'heading':
286
- node.children.forEach(walkInline);
287
- break;
288
- case 'blockquote':
289
- node.children.forEach(walkBlock);
290
- break;
291
- case 'list':
292
- node.children.forEach((item) => item.children.forEach(walkBlock));
293
- break;
294
- case 'table':
295
- node.children.forEach((row) => row.children.forEach((cell) => cell.children.forEach(walkInline)));
296
- break;
297
- default:
298
- break;
299
- }
300
- }
301
- function walkInline(node) {
302
- if (node.type === 'image' && node.url && !node.url.startsWith('data:')) {
303
- images.add(node.url);
304
- }
305
- if ('children' in node && Array.isArray(node.children)) {
306
- node.children.forEach(walkInline);
307
- }
308
- }
309
- nodes.forEach(walkBlock);
310
- return images;
311
- }
312
- function renderChapterXhtml(nodes, bookTitle, images, addIds = false) {
313
- let elementCounter = 0;
314
- const nextId = () => `p${++elementCounter}`;
315
- const body = nodes.map((n) => blockToXhtml(n, images, addIds ? nextId : undefined)).join('\n');
316
- return `<?xml version="1.0" encoding="UTF-8"?>
317
- <!DOCTYPE html>
318
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
319
- <head>
320
- <meta charset="UTF-8"/>
321
- <title>${escapeXml(bookTitle)}</title>
322
- <link rel="stylesheet" type="text/css" href="../styles.css"/>
323
- </head>
324
- <body>
325
- ${body}
326
- </body>
327
- </html>`;
328
- }
329
- function blockToXhtml(node, images, nextId) {
330
- const idAttr = nextId ? ` id="${nextId()}"` : '';
331
- switch (node.type) {
332
- case 'heading': {
333
- const tag = `h${node.depth}`;
334
- return `<${tag}${idAttr}>${inlinesToXhtml(node.children, images)}</${tag}>`;
335
- }
336
- case 'paragraph':
337
- return `<p${idAttr}>${inlinesToXhtml(node.children, images)}</p>`;
338
- case 'blockquote':
339
- return `<blockquote${idAttr}>\n${node.children.map((c) => blockToXhtml(c, images, nextId)).join('\n')}\n</blockquote>`;
340
- case 'list': {
341
- const tag = node.ordered ? 'ol' : 'ul';
342
- const startAttr = node.ordered && node.start && node.start !== 1 ? ` start="${node.start}"` : '';
343
- const items = node.children.map((item) => listItemToXhtml(item, images)).join('\n');
344
- return `<${tag}${idAttr}${startAttr}>\n${items}\n</${tag}>`;
345
- }
346
- case 'code': {
347
- const langAttr = node.lang ? ` class="language-${escapeXml(node.lang)}"` : '';
348
- return `<pre${idAttr}><code${langAttr}>${escapeXml(node.value)}</code></pre>`;
349
- }
350
- case 'thematicBreak':
351
- return `<hr${idAttr}/>`;
352
- case 'table':
353
- return tableToXhtml(node, images, idAttr);
354
- case 'htmlBlock':
355
- // Strip HTML tags for XHTML safety — raw HTML may not be well-formed XML
356
- return `<p${idAttr}>${escapeXml(node.rawHtml.replace(/<[^>]+>/g, ''))}</p>`;
357
- case 'math':
358
- return `<p${idAttr} class="math">${escapeXml(node.value)}</p>`;
359
- default:
360
- return '';
361
- }
362
- }
363
- function listItemToXhtml(item, images) {
364
- const content = item.children.map((c) => blockToXhtml(c, images)).join('\n');
365
- // Unwrap single <p> inside <li> for cleaner output
366
- const unwrapped = item.children.length === 1 && item.children[0].type === 'paragraph'
367
- ? inlinesToXhtml(item.children[0].children, images)
368
- : content;
369
- return `<li>${unwrapped}</li>`;
370
- }
371
- function tableToXhtml(table, images, idAttr = '') {
372
- const rows = table.children;
373
- if (rows.length === 0)
374
- return `<table${idAttr}></table>`;
375
- const headerRow = rows[0];
376
- const bodyRows = rows.slice(1);
377
- const align = table.align ?? [];
378
- function cellToXhtml(cell, tag, colIndex) {
379
- const a = align[colIndex];
380
- const style = a ? ` style="text-align: ${a}"` : '';
381
- return `<${tag}${style}>${inlinesToXhtml(cell.children, images)}</${tag}>`;
382
- }
383
- const thead = `<thead><tr>${headerRow.children.map((c, i) => cellToXhtml(c, 'th', i)).join('')}</tr></thead>`;
384
- const tbody = bodyRows.length > 0
385
- ? `<tbody>${bodyRows.map((row) => `<tr>${row.children.map((c, i) => cellToXhtml(c, 'td', i)).join('')}</tr>`).join('')}</tbody>`
386
- : '';
387
- return `<table${idAttr}>${thead}${tbody}</table>`;
388
- }
389
- function inlinesToXhtml(nodes, images) {
390
- return nodes.map((n) => inlineToXhtml(n, images)).join('');
391
- }
392
- function inlineToXhtml(node, images) {
393
- switch (node.type) {
394
- case 'text':
395
- return escapeXml(node.value);
396
- case 'strong':
397
- return `<strong>${inlinesToXhtml(node.children, images)}</strong>`;
398
- case 'emphasis':
399
- return `<em>${inlinesToXhtml(node.children, images)}</em>`;
400
- case 'delete':
401
- return `<del>${inlinesToXhtml(node.children, images)}</del>`;
402
- case 'inlineCode':
403
- return `<code>${escapeXml(node.value)}</code>`;
404
- case 'link': {
405
- const titleAttr = node.title ? ` title="${escapeXml(node.title)}"` : '';
406
- return `<a href="${escapeXml(node.url)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
407
- }
408
- case 'image': {
409
- const alt = escapeXml(node.alt ?? '');
410
- const resolved = images.get(node.url);
411
- const src = resolved ? `../images/${resolved.filename}` : escapeXml(node.url);
412
- return `<img src="${src}" alt="${alt}"/>`;
413
- }
414
- case 'break':
415
- return '<br/>';
416
- case 'inlineMath':
417
- return `<span class="math">${escapeXml(node.value)}</span>`;
418
- case 'htmlInline':
419
- // Strip tags for XHTML safety
420
- return escapeXml(node.rawHtml.replace(/<[^>]+>/g, ''));
421
- default:
422
- return '';
423
- }
424
- }
425
- // ── SMIL Media Overlays ───────────────────────────────────────────
426
- /**
427
- * Generate an EPUB 3 Media Overlay (SMIL) file for a chapter.
428
- * Maps block-level elements to audio clip ranges for synchronized narration.
429
- */
430
- function generateSmil(chapterFilename, audioInfo, nodes) {
431
- // Count block elements to match the IDs generated by blockToXhtml.
432
- // Must mirror blockToXhtml's recursion: each block gets an ID,
433
- // blockquote children recurse (they pass nextId), but list items do not.
434
- let elementCount = 0;
435
- function countBlocks(node) {
436
- elementCount++;
437
- if (node.type === 'blockquote')
438
- node.children.forEach(countBlocks);
439
- }
440
- nodes.forEach(countBlocks);
441
- if (elementCount === 0)
442
- elementCount = 1;
443
- // Distribute audio duration evenly across elements (best effort without word-level timing)
444
- const clipDuration = audioInfo.duration / elementCount;
445
- const pars = [];
446
- for (let i = 0; i < elementCount; i++) {
447
- const clipStart = formatTime(audioInfo.clipStart + i * clipDuration, true);
448
- const clipEnd = formatTime(audioInfo.clipStart + (i + 1) * clipDuration, true);
449
- pars.push(` <par id="par-${i + 1}">` +
450
- `\n <text src="${chapterFilename}#p${i + 1}"/>` +
451
- `\n <audio src="../audio/${escapeXml(audioInfo.audioFilename)}" clipBegin="${clipStart}" clipEnd="${clipEnd}"/>` +
452
- `\n </par>`);
453
- }
454
- return `<?xml version="1.0" encoding="UTF-8"?>
455
- <smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">
456
- <body>
457
- <seq id="seq-1" epub:textref="${chapterFilename}">
458
- ${pars.join('\n')}
459
- </seq>
460
- </body>
461
- </smil>`;
462
- }
463
- function formatTime(seconds, fractional = false) {
464
- const h = Math.floor(seconds / 3600);
465
- const m = Math.floor((seconds % 3600) / 60);
466
- const s = seconds % 60;
467
- const sPart = fractional ? s.toFixed(3).padStart(6, '0') : String(Math.floor(s)).padStart(2, '0');
468
- return `${h}:${String(m).padStart(2, '0')}:${sPart}`;
469
- }
470
- // ── Package Documents ─────────────────────────────────────────────
471
- const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
472
- <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
473
- <rootfiles>
474
- <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
475
- </rootfiles>
476
- </container>`;
477
- function generateContentOpf(params) {
478
- const { uuid, title, author, language, description, publisher, chapters, images, coverFilename, audioFiles, totalDuration, } = params;
479
- const modified = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
480
- const hasOverlays = chapters.some((c) => c.smilFilename);
481
- // Manifest items
482
- const manifestItems = [
483
- ' <item id="toc" href="toc.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
484
- ' <item id="css" href="styles.css" media-type="text/css"/>',
485
- ];
486
- if (coverFilename) {
487
- const mime = inferMimeType(coverFilename);
488
- manifestItems.push(` <item id="cover-image" href="images/${escapeXml(coverFilename)}" media-type="${mime}" properties="cover-image"/>`);
489
- manifestItems.push(' <item id="cover-page" href="chapters/cover.xhtml" media-type="application/xhtml+xml"/>');
490
- }
491
- for (const chap of chapters) {
492
- const overlayAttr = chap.smilFilename ? ` media-overlay="${chap.id}-overlay"` : '';
493
- manifestItems.push(` <item id="${chap.id}" href="chapters/${escapeXml(chap.filename)}" media-type="application/xhtml+xml"${overlayAttr}/>`);
494
- if (chap.smilFilename) {
495
- manifestItems.push(` <item id="${chap.id}-overlay" href="chapters/${escapeXml(chap.smilFilename)}" media-type="application/smil+xml"/>`);
496
- }
497
- }
498
- // Audio files in manifest
499
- if (audioFiles) {
500
- const usedAudioNames = new Set();
501
- for (const af of audioFiles) {
502
- if (usedAudioNames.has(af.filename))
503
- continue;
504
- usedAudioNames.add(af.filename);
505
- const audioId = `audio-${af.filename.replace(/[^a-zA-Z0-9]/g, '-')}`;
506
- manifestItems.push(` <item id="${audioId}" href="audio/${escapeXml(af.filename)}" media-type="${af.mime}"/>`);
507
- }
508
- }
509
- const usedFilenames = new Set();
510
- for (const [, img] of images) {
511
- if (usedFilenames.has(img.filename))
512
- continue;
513
- usedFilenames.add(img.filename);
514
- const imgId = `img-${img.filename.replace(/[^a-zA-Z0-9]/g, '-')}`;
515
- manifestItems.push(` <item id="${imgId}" href="images/${escapeXml(img.filename)}" media-type="${img.mime}"/>`);
516
- }
517
- // Spine — cover page first (if present), then chapters
518
- const spineEntries = [];
519
- if (coverFilename) {
520
- spineEntries.push(' <itemref idref="cover-page"/>');
521
- }
522
- for (const chap of chapters) {
523
- spineEntries.push(` <itemref idref="${chap.id}"/>`);
524
- }
525
- const spineItems = spineEntries.join('\n');
526
- // Metadata
527
- const metaParts = [
528
- ` <dc:identifier id="uid">urn:uuid:${uuid}</dc:identifier>`,
529
- ` <dc:title>${escapeXml(title)}</dc:title>`,
530
- ` <dc:language>${escapeXml(language)}</dc:language>`,
531
- ` <meta property="dcterms:modified">${modified}</meta>`,
532
- ];
533
- if (author)
534
- metaParts.push(` <dc:creator>${escapeXml(author)}</dc:creator>`);
535
- if (description)
536
- metaParts.push(` <dc:description>${escapeXml(description)}</dc:description>`);
537
- if (publisher)
538
- metaParts.push(` <dc:publisher>${escapeXml(publisher)}</dc:publisher>`);
539
- // Media Overlay metadata
540
- if (hasOverlays && totalDuration) {
541
- metaParts.push(` <meta property="media:duration">${formatTime(totalDuration)}</meta>`);
542
- metaParts.push(' <meta property="media:active-class">epub-media-overlay-active</meta>');
543
- }
544
- return `<?xml version="1.0" encoding="UTF-8"?>
545
- <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
546
- <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
547
- ${metaParts.join('\n')}
548
- </metadata>
549
- <manifest>
550
- ${manifestItems.join('\n')}
551
- </manifest>
552
- <spine>
553
- ${spineItems}
554
- </spine>
555
- </package>`;
556
- }
557
- function generateTocXhtml(chapters, bookTitle) {
558
- const navItems = chapters
559
- .map((chap) => ` <li><a href="chapters/${escapeXml(chap.filename)}">${escapeXml(chap.title)}</a></li>`)
560
- .join('\n');
561
- return `<?xml version="1.0" encoding="UTF-8"?>
562
- <!DOCTYPE html>
563
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
564
- <head>
565
- <meta charset="UTF-8"/>
566
- <title>${escapeXml(bookTitle)}</title>
567
- <link rel="stylesheet" type="text/css" href="styles.css"/>
568
- </head>
569
- <body>
570
- <nav epub:type="toc">
571
- <h1>Table of Contents</h1>
572
- <ol>
573
- ${navItems}
574
- </ol>
575
- </nav>
576
- </body>
577
- </html>`;
578
- }
579
- // ── Stylesheet ────────────────────────────────────────────────────
580
- function generateStylesheet(themeId) {
581
- let themeVars = '';
582
- if (themeId) {
583
- const theme = resolveTheme(themeId);
584
- themeVars = `
585
- --epub-bg: ${theme.colors.background};
586
- --epub-text: ${theme.colors.text};
587
- --epub-primary: ${theme.colors.primary};
588
- --epub-heading-font: ${resolveFontFamily(theme.typography.titleFont, 'serif')};
589
- --epub-body-font: ${resolveFontFamily(theme.typography.bodyFont, 'sans-serif')};`;
590
- }
591
- return `/* Squisq EPUB Stylesheet */
592
- :root {${themeVars}
593
- }
594
-
595
- body {
596
- font-family: var(--epub-body-font, Georgia, 'Times New Roman', serif);
597
- color: var(--epub-text, #1a1a1a);
598
- line-height: 1.7;
599
- margin: 1em 2em;
600
- max-width: 40em;
601
- }
602
-
603
- h1, h2, h3, h4, h5, h6 {
604
- font-family: var(--epub-heading-font, system-ui, sans-serif);
605
- color: var(--epub-primary, #1a1a1a);
606
- margin-top: 1.5em;
607
- margin-bottom: 0.5em;
608
- line-height: 1.3;
609
- }
610
-
611
- h1 { font-size: 2em; }
612
- h2 { font-size: 1.5em; }
613
- h3 { font-size: 1.25em; }
614
-
615
- p {
616
- margin: 0.8em 0;
617
- }
618
-
619
- a {
620
- color: var(--epub-primary, #2563eb);
621
- }
622
-
623
- img {
624
- max-width: 100%;
625
- height: auto;
626
- }
627
-
628
- pre {
629
- background: #f5f5f5;
630
- padding: 1em;
631
- overflow-x: auto;
632
- border-radius: 4px;
633
- font-size: 0.9em;
634
- line-height: 1.4;
635
- }
636
-
637
- code {
638
- font-family: 'Courier New', Courier, monospace;
639
- font-size: 0.9em;
640
- }
641
-
642
- p > code, li > code {
643
- background: #f0f0f0;
644
- padding: 0.1em 0.3em;
645
- border-radius: 3px;
646
- }
647
-
648
- blockquote {
649
- border-left: 3px solid var(--epub-primary, #d1d5db);
650
- margin: 1em 0;
651
- padding: 0.5em 1em;
652
- color: #4b5563;
653
- }
654
-
655
- table {
656
- border-collapse: collapse;
657
- width: 100%;
658
- margin: 1em 0;
659
- }
660
-
661
- th, td {
662
- border: 1px solid #d1d5db;
663
- padding: 0.5em 0.75em;
664
- text-align: left;
665
- }
666
-
667
- th {
668
- background: #f3f4f6;
669
- font-weight: 600;
670
- }
671
-
672
- hr {
673
- border: none;
674
- border-top: 1px solid #d1d5db;
675
- margin: 2em 0;
676
- }
677
-
678
- ul, ol {
679
- margin: 0.8em 0;
680
- padding-left: 1.5em;
681
- }
682
-
683
- li {
684
- margin: 0.3em 0;
685
- }
686
-
687
- .math {
688
- font-family: 'Courier New', Courier, monospace;
689
- font-style: italic;
690
- }
691
-
692
- /* Media Overlay active highlight (narration sync) */
693
- .epub-media-overlay-active {
694
- background-color: rgba(37, 99, 235, 0.12);
695
- }
696
- `;
697
- }
698
- //# sourceMappingURL=export.js.map