@bendyline/squisq-formats 2.0.1 → 2.2.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 +20 -0
  3. package/README.md +1 -1
  4. package/dist/{chunk-CRAVSMPZ.js → chunk-26ISNJ7Y.js} +356 -114
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-HTW2M27H.js → chunk-3NKXBZSR.js} +193 -42
  7. package/dist/{chunk-U32AG3G3.js → chunk-4V3KCHAP.js} +3 -4
  8. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  9. package/dist/{chunk-QRVN6A6E.js → chunk-6S6GU3ZG.js} +5 -6
  10. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  11. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  12. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  13. package/dist/{chunk-XKUMNGBW.js → chunk-EJTNGKEA.js} +5 -8
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  16. package/dist/{chunk-ABVI556T.js → chunk-IPN56VLW.js} +83 -58
  17. package/dist/{chunk-LXYLOOST.js → chunk-JE6LSIHE.js} +83 -22
  18. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  19. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  20. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  21. package/dist/chunk-OGS5VCGJ.js +446 -0
  22. package/dist/{chunk-GVS2XXV6.js → chunk-PJXJI2LY.js} +449 -57
  23. package/dist/{chunk-2KPARF2P.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-VSYHZECT.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-WC7WULGV.js → chunk-X2DEAXNK.js} +62 -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 -10
  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 +6 -3
  61. package/dist/chunk-2KPARF2P.js.map +0 -1
  62. package/dist/chunk-4VUWTSGM.js.map +0 -1
  63. package/dist/chunk-6M7Z25LA.js +0 -46
  64. package/dist/chunk-6M7Z25LA.js.map +0 -1
  65. package/dist/chunk-ABVI556T.js.map +0 -1
  66. package/dist/chunk-CRAVSMPZ.js.map +0 -1
  67. package/dist/chunk-FE6OJV6O.js.map +0 -1
  68. package/dist/chunk-GVS2XXV6.js.map +0 -1
  69. package/dist/chunk-HTW2M27H.js.map +0 -1
  70. package/dist/chunk-LXYLOOST.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-O3GVVND4.js.map +0 -1
  74. package/dist/chunk-ODL3SSPT.js.map +0 -1
  75. package/dist/chunk-PN52A5AA.js.map +0 -1
  76. package/dist/chunk-QRVN6A6E.js.map +0 -1
  77. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  78. package/dist/chunk-SSUPBUF5.js.map +0 -1
  79. package/dist/chunk-U32AG3G3.js.map +0 -1
  80. package/dist/chunk-U4MRIFKL.js.map +0 -1
  81. package/dist/chunk-VJJM2SSH.js +0 -275
  82. package/dist/chunk-VJJM2SSH.js.map +0 -1
  83. package/dist/chunk-VSYHZECT.js.map +0 -1
  84. package/dist/chunk-WC7WULGV.js.map +0 -1
  85. package/dist/chunk-XKUMNGBW.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-H6RXJMHP.js +0 -15
  93. package/dist/extract-H6RXJMHP.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-QVPK3ZCU.js +0 -12
  100. package/dist/layouts-QVPK3ZCU.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 -457
  111. package/src/__tests__/docxImport.test.ts +0 -410
  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 -435
  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 -1267
  143. package/src/docx/import.ts +0 -995
  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 -196
  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,968 +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
-
24
- import JSZip from 'jszip';
25
- import type { Doc, AudioSegment, ThemeRegistry } from '@bendyline/squisq/schemas';
26
- import { resolveFontFamily } from '@bendyline/squisq/schemas';
27
- import { resolveThemeForDoc } from '@bendyline/squisq/doc';
28
- import type {
29
- MarkdownDocument,
30
- MarkdownBlockNode,
31
- MarkdownInlineNode,
32
- MarkdownHeading,
33
- MarkdownParagraph,
34
- MarkdownListItem,
35
- MarkdownTable,
36
- MarkdownTableRow,
37
- MarkdownTableCell,
38
- } from '@bendyline/squisq/markdown';
39
- import { readFrontmatterThemeId, sanitizeUrl } from '@bendyline/squisq/markdown';
40
- import { escapeXml } from '../ooxml/xmlUtils.js';
41
- import { inferMimeType, extractFilename } from '../html/imageUtils.js';
42
- import { extractPlainText } from '../shared/text.js';
43
-
44
- // ── Public API ────────────────────────────────────────────────────
45
-
46
- export interface EpubExportOptions {
47
- /** Book title (default: 'Untitled') */
48
- title?: string;
49
- /** Author name */
50
- author?: string;
51
- /** Book description / summary */
52
- description?: string;
53
- /** BCP-47 language code (default: 'en') */
54
- language?: string;
55
- /** Publisher name */
56
- publisher?: string;
57
- /** Squisq theme ID for CSS styling */
58
- themeId?: string;
59
- /** Explicit caller-owned registry for non-document custom themes. */
60
- themeRegistry?: ThemeRegistry;
61
- /** Pre-resolved image data keyed by relative path as it appears in the markdown */
62
- images?: Map<string, ArrayBuffer>;
63
- /** Cover image data (JPEG or PNG) */
64
- coverImage?: ArrayBuffer;
65
- /**
66
- * Audio narration data keyed by segment src/name.
67
- * When provided alongside audioSegments, EPUB 3 Media Overlays (SMIL)
68
- * are generated for synchronized audio playback.
69
- */
70
- audio?: Map<string, ArrayBuffer>;
71
- /**
72
- * Audio segment metadata (from Doc.audio.segments).
73
- * Required together with `audio` to generate Media Overlays.
74
- * Each segment's duration and startTime are used to build SMIL timing.
75
- */
76
- audioSegments?: AudioSegment[];
77
- /** Total document duration in seconds (used for Media Overlay metadata) */
78
- totalDuration?: number;
79
- }
80
-
81
- /**
82
- * Convert a MarkdownDocument to an EPUB 3 file.
83
- *
84
- * Chapters are split at H1/H2 heading boundaries. All referenced images
85
- * (provided via `options.images`) are embedded in the archive.
86
- */
87
- export async function markdownDocToEpub(
88
- doc: MarkdownDocument,
89
- options: EpubExportOptions = {},
90
- ): Promise<ArrayBuffer> {
91
- const fmTitle = doc.frontmatter?.title;
92
- const fmAuthor = doc.frontmatter?.author;
93
- const title = options.title ?? (typeof fmTitle === 'string' ? fmTitle : 'Untitled');
94
- const author = options.author ?? (typeof fmAuthor === 'string' ? fmAuthor : '');
95
- const language = options.language ?? 'en';
96
- const description = options.description ?? '';
97
- const publisher = options.publisher ?? '';
98
- const uuid = createEpubUuid();
99
-
100
- // Split document into chapters
101
- const chapters = splitIntoChapters(doc.children);
102
-
103
- // Collect images referenced in the document, deduplicating filenames
104
- const imageEntries = collectDocImages(doc.children);
105
- const resolvedImages = new Map<string, { data: ArrayBuffer; mime: string; filename: string }>();
106
- const usedImageNames = new Set<string>();
107
- if (options.images) {
108
- for (const src of imageEntries) {
109
- const data = options.images.get(src);
110
- if (data) {
111
- const filename = uniqueFilename(safeArchiveBasename(src, 'image'), usedImageNames);
112
- usedImageNames.add(filename);
113
- resolvedImages.set(src, { data, mime: inferMimeType(filename), filename });
114
- }
115
- }
116
- }
117
-
118
- // Generate theme CSS — honor an explicit themeId, else the doc's frontmatter
119
- // theme (`squisq-theme` / legacy), mirroring the other export formats.
120
- const css = generateStylesheet(
121
- options.themeId ?? readFrontmatterThemeId(doc.frontmatter),
122
- doc,
123
- options.themeRegistry,
124
- );
125
-
126
- // Build the ZIP
127
- const zip = new JSZip();
128
-
129
- // mimetype must be first entry, stored (not compressed)
130
- zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
131
-
132
- // META-INF/container.xml
133
- zip.file('META-INF/container.xml', CONTAINER_XML);
134
-
135
- // OEBPS/styles.css
136
- zip.file('OEBPS/styles.css', css);
137
-
138
- // OEBPS/images/*
139
- for (const [, img] of resolvedImages) {
140
- zip.file(`OEBPS/images/${img.filename}`, img.data);
141
- }
142
-
143
- // Cover image — detect PNG vs JPEG from magic bytes.
144
- // When provided, generates a cover.xhtml page in the spine so e-readers
145
- // (especially Kindle) display the cover as both thumbnail and first page.
146
- let coverFilename: string | undefined;
147
- if (options.coverImage) {
148
- const bytes = new Uint8Array(options.coverImage);
149
- const isPng =
150
- bytes.length >= 4 &&
151
- bytes[0] === 0x89 &&
152
- bytes[1] === 0x50 &&
153
- bytes[2] === 0x4e &&
154
- bytes[3] === 0x47;
155
- coverFilename = uniqueFilename(isPng ? 'cover.png' : 'cover.jpg', usedImageNames);
156
- usedImageNames.add(coverFilename);
157
- zip.file(`OEBPS/images/${coverFilename}`, options.coverImage);
158
-
159
- // Generate cover XHTML page — full-bleed image, no margins
160
- const coverXhtml = `<?xml version="1.0" encoding="UTF-8"?>
161
- <!DOCTYPE html>
162
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
163
- <head>
164
- <meta charset="UTF-8"/>
165
- <title>Cover</title>
166
- <style>
167
- body { margin: 0; padding: 0; text-align: center; }
168
- img { max-width: 100%; max-height: 100%; }
169
- </style>
170
- </head>
171
- <body>
172
- <img src="../images/${escapeXml(coverFilename)}" alt="Cover"/>
173
- </body>
174
- </html>`;
175
- zip.file('OEBPS/chapters/cover.xhtml', coverXhtml);
176
- }
177
-
178
- // ── Audio narration ──────────────────────────────────────────────
179
- const audioMap = options.audio;
180
- const audioSegments = options.audioSegments;
181
- const hasAudio = audioMap && audioSegments && audioSegments.length > 0;
182
- // Per-segment audio file info, indexed by segment index (null if data missing)
183
- const segmentAudioFiles: ({ filename: string; mime: string } | null)[] = [];
184
- const allAudioFiles: { filename: string; mime: string }[] = [];
185
- const audioFilenameBySource = new Map<string, string>();
186
- const usedAudioFilenames = new Set<string>();
187
-
188
- if (hasAudio) {
189
- for (const seg of audioSegments) {
190
- const data = audioMap.get(seg.src) ?? audioMap.get(seg.name);
191
- if (data) {
192
- const sourceKey = seg.src || seg.name;
193
- let finalName = audioFilenameBySource.get(sourceKey);
194
- let isNewFile = false;
195
- if (!finalName) {
196
- const basename = safeArchiveBasename(sourceKey || 'narration', 'narration');
197
- const withExtension = basename.includes('.') ? basename : `${basename}.mp3`;
198
- finalName = uniqueFilename(withExtension, usedAudioFilenames);
199
- audioFilenameBySource.set(sourceKey, finalName);
200
- usedAudioFilenames.add(finalName);
201
- zip.file(`OEBPS/audio/${finalName}`, data);
202
- isNewFile = true;
203
- }
204
- const info = { filename: finalName, mime: inferMimeType(finalName) };
205
- segmentAudioFiles.push(info);
206
- if (isNewFile) allAudioFiles.push(info);
207
- } else {
208
- segmentAudioFiles.push(null);
209
- }
210
- }
211
- }
212
-
213
- // Build chapter-to-audio mapping for SMIL overlays
214
- const chapterAudio: (ChapterAudioInfo | null)[] = [];
215
- if (hasAudio && allAudioFiles.length > 0) {
216
- if (chapters.length !== audioSegments.length) {
217
- console.warn(
218
- `EPUB: ${chapters.length} chapters but ${audioSegments.length} audio segments — ` +
219
- `extra chapters will reuse the last segment's audio`,
220
- );
221
- }
222
- for (let i = 0; i < chapters.length; i++) {
223
- const segIdx = Math.min(i, audioSegments.length - 1);
224
- const seg = audioSegments[segIdx];
225
- const audioFile = segmentAudioFiles[segIdx];
226
- if (audioFile) {
227
- chapterAudio.push({
228
- audioFilename: audioFile.filename,
229
- clipStart: 0,
230
- clipEnd: seg.duration,
231
- duration: seg.duration,
232
- });
233
- } else {
234
- chapterAudio.push(null);
235
- }
236
- }
237
- }
238
-
239
- // OEBPS/chapters/*.xhtml + optional SMIL overlays
240
- const chapterFiles: ChapterFileInfo[] = [];
241
- for (let i = 0; i < chapters.length; i++) {
242
- const chap = chapters[i];
243
- const num = String(i + 1).padStart(3, '0');
244
- const id = `chapter-${num}`;
245
- const filename = `${id}.xhtml`;
246
- const audioInfo = chapterAudio[i] ?? null;
247
-
248
- // Render XHTML with element IDs for SMIL references when audio is present
249
- const xhtml = renderChapterXhtml(chap.nodes, title, resolvedImages, audioInfo !== null);
250
- zip.file(`OEBPS/chapters/${filename}`, xhtml);
251
-
252
- let smilFilename: string | undefined;
253
- if (audioInfo) {
254
- smilFilename = `${id}.smil`;
255
- const smil = generateSmil(filename, audioInfo, chap.nodes);
256
- zip.file(`OEBPS/chapters/${smilFilename}`, smil);
257
- }
258
-
259
- chapterFiles.push({
260
- id,
261
- filename,
262
- title: chap.title,
263
- smilFilename,
264
- duration: audioInfo?.duration,
265
- });
266
- }
267
-
268
- // OEBPS/toc.xhtml (EPUB 3 nav)
269
- zip.file('OEBPS/toc.xhtml', generateTocXhtml(chapterFiles, title));
270
-
271
- // OEBPS/content.opf
272
- zip.file(
273
- 'OEBPS/content.opf',
274
- generateContentOpf({
275
- uuid,
276
- title,
277
- author,
278
- language,
279
- description,
280
- publisher,
281
- chapters: chapterFiles,
282
- images: resolvedImages,
283
- coverFilename,
284
- audioFiles: allAudioFiles,
285
- totalDuration: options.totalDuration,
286
- }),
287
- );
288
-
289
- const blob = await zip.generateAsync({
290
- type: 'arraybuffer',
291
- compression: 'DEFLATE',
292
- compressionOptions: { level: 6 },
293
- // mimetype was already set to STORE above; JSZip respects per-file options
294
- });
295
-
296
- return blob;
297
- }
298
-
299
- /**
300
- * Convert a squisq Doc to an EPUB 3 file.
301
- *
302
- * Convenience wrapper: Doc → MarkdownDocument → EPUB.
303
- * When the Doc has audio segments and `options.audio` is provided,
304
- * EPUB 3 Media Overlays are generated for narrated playback.
305
- */
306
- export async function docToEpub(doc: Doc, options: EpubExportOptions = {}): Promise<ArrayBuffer> {
307
- const { docToMarkdown } = await import('@bendyline/squisq/doc');
308
- const markdownDoc = docToMarkdown(doc);
309
-
310
- // Thread audio segment metadata from the Doc into options
311
- const epubOptions: EpubExportOptions = { ...options };
312
- if (doc.audio?.segments?.length && !epubOptions.audioSegments) {
313
- epubOptions.audioSegments = doc.audio.segments;
314
- }
315
- if (doc.duration && !epubOptions.totalDuration) {
316
- epubOptions.totalDuration = doc.duration;
317
- }
318
-
319
- return markdownDocToEpub(markdownDoc, epubOptions);
320
- }
321
-
322
- // ── Chapter Splitting ─────────────────────────────────────────────
323
-
324
- interface Chapter {
325
- title: string;
326
- nodes: MarkdownBlockNode[];
327
- }
328
-
329
- interface ChapterFileInfo {
330
- id: string;
331
- filename: string;
332
- title: string;
333
- smilFilename?: string;
334
- duration?: number;
335
- }
336
-
337
- interface ChapterAudioInfo {
338
- audioFilename: string;
339
- clipStart: number;
340
- clipEnd: number;
341
- duration: number;
342
- }
343
-
344
- function splitIntoChapters(nodes: MarkdownBlockNode[]): Chapter[] {
345
- const chapters: Chapter[] = [];
346
- let currentNodes: MarkdownBlockNode[] = [];
347
- let currentTitle = 'Untitled';
348
-
349
- for (const node of nodes) {
350
- if (node.type === 'heading' && node.depth <= 2) {
351
- // Flush previous chapter
352
- if (currentNodes.length > 0) {
353
- chapters.push({ title: currentTitle, nodes: currentNodes });
354
- }
355
- currentTitle = extractHeadingText(node);
356
- currentNodes = [node];
357
- } else {
358
- currentNodes.push(node);
359
- }
360
- }
361
-
362
- // Flush remaining
363
- if (currentNodes.length > 0) {
364
- chapters.push({ title: currentTitle, nodes: currentNodes });
365
- }
366
-
367
- // If no chapters were created, wrap everything as one
368
- if (chapters.length === 0) {
369
- chapters.push({ title: 'Untitled', nodes: [] });
370
- }
371
-
372
- return chapters;
373
- }
374
-
375
- function extractHeadingText(heading: MarkdownHeading): string {
376
- return extractPlainText(heading.children);
377
- }
378
-
379
- // ── Image Collection ──────────────────────────────────────────────
380
-
381
- function collectDocImages(nodes: MarkdownBlockNode[]): Set<string> {
382
- const images = new Set<string>();
383
-
384
- function walkBlock(node: MarkdownBlockNode): void {
385
- switch (node.type) {
386
- case 'paragraph':
387
- case 'heading':
388
- node.children.forEach(walkInline);
389
- break;
390
- case 'blockquote':
391
- node.children.forEach(walkBlock);
392
- break;
393
- case 'list':
394
- node.children.forEach((item) => item.children.forEach(walkBlock));
395
- break;
396
- case 'table':
397
- node.children.forEach((row) =>
398
- row.children.forEach((cell) => cell.children.forEach(walkInline)),
399
- );
400
- break;
401
- default:
402
- break;
403
- }
404
- }
405
-
406
- function walkInline(node: MarkdownInlineNode): void {
407
- if (node.type === 'image' && node.url && !node.url.startsWith('data:')) {
408
- images.add(node.url);
409
- }
410
- if ('children' in node && Array.isArray(node.children)) {
411
- (node.children as MarkdownInlineNode[]).forEach(walkInline);
412
- }
413
- }
414
-
415
- nodes.forEach(walkBlock);
416
- return images;
417
- }
418
-
419
- // ── XHTML Rendering ───────────────────────────────────────────────
420
-
421
- type ImageMap = Map<string, { data: ArrayBuffer; mime: string; filename: string }>;
422
-
423
- function renderChapterXhtml(
424
- nodes: MarkdownBlockNode[],
425
- bookTitle: string,
426
- images: ImageMap,
427
- addIds = false,
428
- ): string {
429
- let elementCounter = 0;
430
- const nextId = () => `p${++elementCounter}`;
431
- const body = nodes.map((n) => blockToXhtml(n, images, addIds ? nextId : undefined)).join('\n');
432
- return `<?xml version="1.0" encoding="UTF-8"?>
433
- <!DOCTYPE html>
434
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
435
- <head>
436
- <meta charset="UTF-8"/>
437
- <title>${escapeXml(bookTitle)}</title>
438
- <link rel="stylesheet" type="text/css" href="../styles.css"/>
439
- </head>
440
- <body>
441
- ${body}
442
- </body>
443
- </html>`;
444
- }
445
-
446
- function blockToXhtml(node: MarkdownBlockNode, images: ImageMap, nextId?: () => string): string {
447
- const idAttr = nextId ? ` id="${nextId()}"` : '';
448
-
449
- switch (node.type) {
450
- case 'heading': {
451
- const tag = `h${node.depth}`;
452
- return `<${tag}${idAttr}>${inlinesToXhtml(node.children, images)}</${tag}>`;
453
- }
454
-
455
- case 'paragraph':
456
- return `<p${idAttr}>${inlinesToXhtml(node.children, images)}</p>`;
457
-
458
- case 'blockquote':
459
- return `<blockquote${idAttr}>\n${node.children.map((c) => blockToXhtml(c, images, nextId)).join('\n')}\n</blockquote>`;
460
-
461
- case 'list': {
462
- const tag = node.ordered ? 'ol' : 'ul';
463
- const startAttr =
464
- node.ordered && node.start && node.start !== 1 ? ` start="${node.start}"` : '';
465
- const items = node.children.map((item) => listItemToXhtml(item, images)).join('\n');
466
- return `<${tag}${idAttr}${startAttr}>\n${items}\n</${tag}>`;
467
- }
468
-
469
- case 'code': {
470
- const langAttr = node.lang ? ` class="language-${escapeXml(node.lang)}"` : '';
471
- return `<pre${idAttr}><code${langAttr}>${escapeXml(node.value)}</code></pre>`;
472
- }
473
-
474
- case 'thematicBreak':
475
- return `<hr${idAttr}/>`;
476
-
477
- case 'table':
478
- return tableToXhtml(node as MarkdownTable, images, idAttr);
479
-
480
- case 'htmlBlock':
481
- // Strip HTML tags for XHTML safety — raw HTML may not be well-formed XML
482
- return `<p${idAttr}>${escapeXml(node.rawHtml.replace(/<[^>]+>/g, ''))}</p>`;
483
-
484
- case 'math':
485
- return `<p${idAttr} class="math">${escapeXml(node.value)}</p>`;
486
-
487
- default:
488
- return '';
489
- }
490
- }
491
-
492
- function listItemToXhtml(item: MarkdownListItem, images: ImageMap): string {
493
- const content = item.children.map((c) => blockToXhtml(c, images)).join('\n');
494
- // Unwrap single <p> inside <li> for cleaner output
495
- const unwrapped =
496
- item.children.length === 1 && item.children[0].type === 'paragraph'
497
- ? inlinesToXhtml((item.children[0] as MarkdownParagraph).children, images)
498
- : content;
499
- return `<li>${unwrapped}</li>`;
500
- }
501
-
502
- function tableToXhtml(table: MarkdownTable, images: ImageMap, idAttr = ''): string {
503
- const rows = table.children;
504
- if (rows.length === 0) return `<table${idAttr}></table>`;
505
-
506
- const headerRow = rows[0];
507
- const bodyRows = rows.slice(1);
508
- const align = table.align ?? [];
509
-
510
- function cellToXhtml(cell: MarkdownTableCell, tag: 'th' | 'td', colIndex: number): string {
511
- const a = align[colIndex];
512
- const style = a ? ` style="text-align: ${a}"` : '';
513
- return `<${tag}${style}>${inlinesToXhtml(cell.children, images)}</${tag}>`;
514
- }
515
-
516
- const thead = `<thead><tr>${headerRow.children.map((c, i) => cellToXhtml(c, 'th', i)).join('')}</tr></thead>`;
517
- const tbody =
518
- bodyRows.length > 0
519
- ? `<tbody>${bodyRows.map((row: MarkdownTableRow) => `<tr>${row.children.map((c, i) => cellToXhtml(c, 'td', i)).join('')}</tr>`).join('')}</tbody>`
520
- : '';
521
-
522
- return `<table${idAttr}>${thead}${tbody}</table>`;
523
- }
524
-
525
- function inlinesToXhtml(nodes: MarkdownInlineNode[], images: ImageMap): string {
526
- return nodes.map((n) => inlineToXhtml(n, images)).join('');
527
- }
528
-
529
- function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
530
- switch (node.type) {
531
- case 'text':
532
- return escapeXml(node.value);
533
-
534
- case 'strong':
535
- return `<strong>${inlinesToXhtml(node.children, images)}</strong>`;
536
-
537
- case 'emphasis':
538
- return `<em>${inlinesToXhtml(node.children, images)}</em>`;
539
-
540
- case 'delete':
541
- return `<del>${inlinesToXhtml(node.children, images)}</del>`;
542
-
543
- case 'inlineCode':
544
- return `<code>${escapeXml(node.value)}</code>`;
545
-
546
- case 'link': {
547
- const href = sanitizeUrl(node.url, 'link');
548
- if (!href) return inlinesToXhtml(node.children, images);
549
- const titleAttr = node.title ? ` title="${escapeXml(node.title)}"` : '';
550
- return `<a href="${escapeXml(href)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
551
- }
552
-
553
- case 'image': {
554
- const alt = escapeXml(node.alt ?? '');
555
- const resolved = images.get(node.url);
556
- const safeSrc = resolved ? `../images/${resolved.filename}` : sanitizeUrl(node.url, 'media');
557
- if (!safeSrc) return alt;
558
- const src = escapeXml(safeSrc);
559
- return `<img src="${src}" alt="${alt}"/>`;
560
- }
561
-
562
- case 'break':
563
- return '<br/>';
564
-
565
- case 'inlineMath':
566
- return `<span class="math">${escapeXml(node.value)}</span>`;
567
-
568
- case 'htmlInline':
569
- // Strip tags for XHTML safety
570
- return escapeXml(node.rawHtml.replace(/<[^>]+>/g, ''));
571
-
572
- default:
573
- return '';
574
- }
575
- }
576
-
577
- /**
578
- * Produce an EPUB identifier without assuming `crypto.randomUUID()` exists.
579
- * `randomUUID` is unavailable in older/non-secure browser contexts; use
580
- * `getRandomValues` when possible and retain a non-cryptographic last resort
581
- * because this UUID is an identifier, not a security token.
582
- */
583
- function createEpubUuid(): string {
584
- const webCrypto = globalThis.crypto;
585
- if (typeof webCrypto?.randomUUID === 'function') return webCrypto.randomUUID();
586
-
587
- const bytes = new Uint8Array(16);
588
- if (typeof webCrypto?.getRandomValues === 'function') {
589
- webCrypto.getRandomValues(bytes);
590
- } else {
591
- let seed = Date.now() ^ Math.floor(Math.random() * 0x7fffffff);
592
- for (let i = 0; i < bytes.length; i++) {
593
- seed = Math.imul(seed ^ (seed >>> 15), 1 | seed);
594
- bytes[i] = seed & 0xff;
595
- }
596
- }
597
- // RFC 4122 version 4 / variant 1 bits.
598
- bytes[6] = (bytes[6]! & 0x0f) | 0x40;
599
- bytes[8] = (bytes[8]! & 0x3f) | 0x80;
600
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
601
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
602
- }
603
-
604
- /** Flatten an external path to a safe archive basename. */
605
- function safeArchiveBasename(path: string, fallback: string): string {
606
- const filename = extractFilename(path.replace(/\\/g, '/'));
607
- const safe = Array.from(filename, (character) => {
608
- const codePoint = character.codePointAt(0) ?? 0;
609
- return character === '\\' || character === '/' || codePoint < 0x20 || codePoint === 0x7f
610
- ? '_'
611
- : character;
612
- })
613
- .join('')
614
- .trim();
615
- return !safe || safe === '.' || safe === '..' ? fallback : safe;
616
- }
617
-
618
- /** Return a basename that does not collide with a prior archive member. */
619
- function uniqueFilename(filename: string, used: ReadonlySet<string>): string {
620
- if (!used.has(filename)) return filename;
621
- const dot = filename.lastIndexOf('.');
622
- const base = dot > 0 ? filename.slice(0, dot) : filename;
623
- const ext = dot > 0 ? filename.slice(dot) : '';
624
- let suffix = 2;
625
- while (used.has(`${base}-${suffix}${ext}`)) suffix++;
626
- return `${base}-${suffix}${ext}`;
627
- }
628
-
629
- // ── SMIL Media Overlays ───────────────────────────────────────────
630
-
631
- /**
632
- * Generate an EPUB 3 Media Overlay (SMIL) file for a chapter.
633
- * Maps block-level elements to audio clip ranges for synchronized narration.
634
- */
635
- function generateSmil(
636
- chapterFilename: string,
637
- audioInfo: ChapterAudioInfo,
638
- nodes: MarkdownBlockNode[],
639
- ): string {
640
- // Count block elements to match the IDs generated by blockToXhtml.
641
- // Must mirror blockToXhtml's recursion: each block gets an ID,
642
- // blockquote children recurse (they pass nextId), but list items do not.
643
- let elementCount = 0;
644
- function countBlocks(node: MarkdownBlockNode): void {
645
- elementCount++;
646
- if (node.type === 'blockquote') node.children.forEach(countBlocks);
647
- }
648
- nodes.forEach(countBlocks);
649
-
650
- if (elementCount === 0) elementCount = 1;
651
-
652
- // Distribute audio duration evenly across elements (best effort without word-level timing)
653
- const clipDuration = audioInfo.duration / elementCount;
654
- const pars: string[] = [];
655
-
656
- for (let i = 0; i < elementCount; i++) {
657
- const clipStart = formatTime(audioInfo.clipStart + i * clipDuration, true);
658
- const clipEnd = formatTime(audioInfo.clipStart + (i + 1) * clipDuration, true);
659
- pars.push(
660
- ` <par id="par-${i + 1}">` +
661
- `\n <text src="${chapterFilename}#p${i + 1}"/>` +
662
- `\n <audio src="../audio/${escapeXml(audioInfo.audioFilename)}" clipBegin="${clipStart}" clipEnd="${clipEnd}"/>` +
663
- `\n </par>`,
664
- );
665
- }
666
-
667
- return `<?xml version="1.0" encoding="UTF-8"?>
668
- <smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">
669
- <body>
670
- <seq id="seq-1" epub:textref="${chapterFilename}">
671
- ${pars.join('\n')}
672
- </seq>
673
- </body>
674
- </smil>`;
675
- }
676
-
677
- function formatTime(seconds: number, fractional = false): string {
678
- const h = Math.floor(seconds / 3600);
679
- const m = Math.floor((seconds % 3600) / 60);
680
- const s = seconds % 60;
681
- const sPart = fractional ? s.toFixed(3).padStart(6, '0') : String(Math.floor(s)).padStart(2, '0');
682
- return `${h}:${String(m).padStart(2, '0')}:${sPart}`;
683
- }
684
-
685
- // ── Package Documents ─────────────────────────────────────────────
686
-
687
- const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
688
- <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
689
- <rootfiles>
690
- <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
691
- </rootfiles>
692
- </container>`;
693
-
694
- interface OpfParams {
695
- uuid: string;
696
- title: string;
697
- author: string;
698
- language: string;
699
- description: string;
700
- publisher: string;
701
- chapters: ChapterFileInfo[];
702
- images: ImageMap;
703
- coverFilename?: string;
704
- audioFiles?: { filename: string; mime: string }[];
705
- totalDuration?: number;
706
- }
707
-
708
- function generateContentOpf(params: OpfParams): string {
709
- const {
710
- uuid,
711
- title,
712
- author,
713
- language,
714
- description,
715
- publisher,
716
- chapters,
717
- images,
718
- coverFilename,
719
- audioFiles,
720
- totalDuration,
721
- } = params;
722
- const modified = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
723
- const hasOverlays = chapters.some((c) => c.smilFilename);
724
-
725
- // Manifest items
726
- const manifestItems: string[] = [
727
- ' <item id="toc" href="toc.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
728
- ' <item id="css" href="styles.css" media-type="text/css"/>',
729
- ];
730
-
731
- if (coverFilename) {
732
- const mime = inferMimeType(coverFilename);
733
- manifestItems.push(
734
- ` <item id="cover-image" href="images/${escapeXml(coverFilename)}" media-type="${mime}" properties="cover-image"/>`,
735
- );
736
- manifestItems.push(
737
- ' <item id="cover-page" href="chapters/cover.xhtml" media-type="application/xhtml+xml"/>',
738
- );
739
- }
740
-
741
- for (const chap of chapters) {
742
- const overlayAttr = chap.smilFilename ? ` media-overlay="${chap.id}-overlay"` : '';
743
- manifestItems.push(
744
- ` <item id="${chap.id}" href="chapters/${escapeXml(chap.filename)}" media-type="application/xhtml+xml"${overlayAttr}/>`,
745
- );
746
- if (chap.smilFilename) {
747
- manifestItems.push(
748
- ` <item id="${chap.id}-overlay" href="chapters/${escapeXml(chap.smilFilename)}" media-type="application/smil+xml"/>`,
749
- );
750
- }
751
- }
752
-
753
- // Audio files in manifest
754
- if (audioFiles) {
755
- const usedAudioNames = new Set<string>();
756
- for (const af of audioFiles) {
757
- if (usedAudioNames.has(af.filename)) continue;
758
- usedAudioNames.add(af.filename);
759
- const audioId = `audio-${af.filename.replace(/[^a-zA-Z0-9]/g, '-')}`;
760
- manifestItems.push(
761
- ` <item id="${audioId}" href="audio/${escapeXml(af.filename)}" media-type="${af.mime}"/>`,
762
- );
763
- }
764
- }
765
-
766
- const usedFilenames = new Set<string>();
767
- for (const [, img] of images) {
768
- if (usedFilenames.has(img.filename)) continue;
769
- usedFilenames.add(img.filename);
770
- const imgId = `img-${img.filename.replace(/[^a-zA-Z0-9]/g, '-')}`;
771
- manifestItems.push(
772
- ` <item id="${imgId}" href="images/${escapeXml(img.filename)}" media-type="${img.mime}"/>`,
773
- );
774
- }
775
-
776
- // Spine — cover page first (if present), then chapters
777
- const spineEntries: string[] = [];
778
- if (coverFilename) {
779
- spineEntries.push(' <itemref idref="cover-page"/>');
780
- }
781
- for (const chap of chapters) {
782
- spineEntries.push(` <itemref idref="${chap.id}"/>`);
783
- }
784
- const spineItems = spineEntries.join('\n');
785
-
786
- // Metadata
787
- const metaParts = [
788
- ` <dc:identifier id="uid">urn:uuid:${uuid}</dc:identifier>`,
789
- ` <dc:title>${escapeXml(title)}</dc:title>`,
790
- ` <dc:language>${escapeXml(language)}</dc:language>`,
791
- ` <meta property="dcterms:modified">${modified}</meta>`,
792
- ];
793
- if (author) metaParts.push(` <dc:creator>${escapeXml(author)}</dc:creator>`);
794
- if (description) metaParts.push(` <dc:description>${escapeXml(description)}</dc:description>`);
795
- if (publisher) metaParts.push(` <dc:publisher>${escapeXml(publisher)}</dc:publisher>`);
796
-
797
- // Media Overlay metadata
798
- if (hasOverlays && totalDuration) {
799
- metaParts.push(` <meta property="media:duration">${formatTime(totalDuration)}</meta>`);
800
- metaParts.push(' <meta property="media:active-class">epub-media-overlay-active</meta>');
801
- }
802
-
803
- return `<?xml version="1.0" encoding="UTF-8"?>
804
- <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
805
- <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
806
- ${metaParts.join('\n')}
807
- </metadata>
808
- <manifest>
809
- ${manifestItems.join('\n')}
810
- </manifest>
811
- <spine>
812
- ${spineItems}
813
- </spine>
814
- </package>`;
815
- }
816
-
817
- function generateTocXhtml(chapters: ChapterFileInfo[], bookTitle: string): string {
818
- const navItems = chapters
819
- .map(
820
- (chap) =>
821
- ` <li><a href="chapters/${escapeXml(chap.filename)}">${escapeXml(chap.title)}</a></li>`,
822
- )
823
- .join('\n');
824
-
825
- return `<?xml version="1.0" encoding="UTF-8"?>
826
- <!DOCTYPE html>
827
- <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
828
- <head>
829
- <meta charset="UTF-8"/>
830
- <title>${escapeXml(bookTitle)}</title>
831
- <link rel="stylesheet" type="text/css" href="styles.css"/>
832
- </head>
833
- <body>
834
- <nav epub:type="toc">
835
- <h1>Table of Contents</h1>
836
- <ol>
837
- ${navItems}
838
- </ol>
839
- </nav>
840
- </body>
841
- </html>`;
842
- }
843
-
844
- // ── Stylesheet ────────────────────────────────────────────────────
845
-
846
- function generateStylesheet(
847
- themeId: string | undefined,
848
- doc?: MarkdownDocument,
849
- registry?: ThemeRegistry,
850
- ): string {
851
- let themeVars = '';
852
- if (themeId) {
853
- const theme = resolveThemeForDoc(doc, themeId, registry);
854
- themeVars = `
855
- --epub-bg: ${theme.colors.background};
856
- --epub-text: ${theme.colors.text};
857
- --epub-primary: ${theme.colors.primary};
858
- --epub-heading-font: ${resolveFontFamily(theme.typography.titleFont, 'serif')};
859
- --epub-body-font: ${resolveFontFamily(theme.typography.bodyFont, 'sans-serif')};`;
860
- }
861
-
862
- return `/* Squisq EPUB Stylesheet */
863
- :root {${themeVars}
864
- }
865
-
866
- body {
867
- font-family: var(--epub-body-font, Georgia, 'Times New Roman', serif);
868
- color: var(--epub-text, #1a1a1a);
869
- line-height: 1.7;
870
- margin: 1em 2em;
871
- max-width: 40em;
872
- }
873
-
874
- h1, h2, h3, h4, h5, h6 {
875
- font-family: var(--epub-heading-font, system-ui, sans-serif);
876
- color: var(--epub-primary, #1a1a1a);
877
- margin-top: 1.5em;
878
- margin-bottom: 0.5em;
879
- line-height: 1.3;
880
- }
881
-
882
- h1 { font-size: 2em; }
883
- h2 { font-size: 1.5em; }
884
- h3 { font-size: 1.25em; }
885
-
886
- p {
887
- margin: 0.8em 0;
888
- }
889
-
890
- a {
891
- color: var(--epub-primary, #2563eb);
892
- }
893
-
894
- img {
895
- max-width: 100%;
896
- height: auto;
897
- }
898
-
899
- pre {
900
- background: #f5f5f5;
901
- padding: 1em;
902
- overflow-x: auto;
903
- border-radius: 4px;
904
- font-size: 0.9em;
905
- line-height: 1.4;
906
- }
907
-
908
- code {
909
- font-family: 'Courier New', Courier, monospace;
910
- font-size: 0.9em;
911
- }
912
-
913
- p > code, li > code {
914
- background: #f0f0f0;
915
- padding: 0.1em 0.3em;
916
- border-radius: 3px;
917
- }
918
-
919
- blockquote {
920
- border-left: 3px solid var(--epub-primary, #d1d5db);
921
- margin: 1em 0;
922
- padding: 0.5em 1em;
923
- color: #4b5563;
924
- }
925
-
926
- table {
927
- border-collapse: collapse;
928
- width: 100%;
929
- margin: 1em 0;
930
- }
931
-
932
- th, td {
933
- border: 1px solid #d1d5db;
934
- padding: 0.5em 0.75em;
935
- text-align: left;
936
- }
937
-
938
- th {
939
- background: #f3f4f6;
940
- font-weight: 600;
941
- }
942
-
943
- hr {
944
- border: none;
945
- border-top: 1px solid #d1d5db;
946
- margin: 2em 0;
947
- }
948
-
949
- ul, ol {
950
- margin: 0.8em 0;
951
- padding-left: 1.5em;
952
- }
953
-
954
- li {
955
- margin: 0.3em 0;
956
- }
957
-
958
- .math {
959
- font-family: 'Courier New', Courier, monospace;
960
- font-style: italic;
961
- }
962
-
963
- /* Media Overlay active highlight (narration sync) */
964
- .epub-media-overlay-active {
965
- background-color: rgba(37, 99, 235, 0.12);
966
- }
967
- `;
968
- }