@uniweb/press 0.3.0 → 0.4.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.
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Internal EPUB3 format adapter.
3
+ *
4
+ * Consumes the 'html' passthrough shape shared with the Paged.js adapter —
5
+ * `{ sections: string[], metadata }` — and produces a valid, reflowable
6
+ * EPUB3 Blob. Each registered section becomes one chapter XHTML file in
7
+ * the EPUB spine.
8
+ *
9
+ * Input-shape sharing: a foundation that wired `useDocumentOutput(block,
10
+ * 'html', fragment)` for Paged.js gets EPUB for free — the adapter
11
+ * descriptor declares `consumes: 'html', ir: false` (see
12
+ * src/adapters/dispatch.js).
13
+ *
14
+ * Three EPUB envelope invariants this adapter upholds — the analogues of
15
+ * the three docx image invariants. All three must hold or e-readers reject
16
+ * the file:
17
+ *
18
+ * 1. `mimetype` is the FIRST entry in the ZIP and stored uncompressed
19
+ * (compression method 0). Readers sniff the first ~60 bytes.
20
+ * 2. Every item in `<spine>` appears in `<manifest>` and vice versa.
21
+ * Mismatches trigger reader errors ("malformed package").
22
+ * 3. Image paths in XHTML exactly match manifest paths including
23
+ * extension and case (XHTML paths are relative to `OEBPS/`).
24
+ *
25
+ * This module is internal. It is NOT listed in package.json's exports
26
+ * field; consumers reach it only via the dynamic import inside
27
+ * adapters/dispatch.js, which keeps jszip + the emitter out of the main
28
+ * bundle until compile('epub') runs.
29
+ */
30
+
31
+ import JSZip from 'jszip'
32
+ import { parseFragment } from 'parse5'
33
+ import { fetchAssets } from '../assets/fetch.js'
34
+
35
+ // ============================================================================
36
+ // Public API
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Compile passthrough HTML output into an EPUB3 Blob.
41
+ *
42
+ * @param {Object} input - Output of compileOutputs(store, 'epub').
43
+ * @param {string[]} input.sections - Rendered HTML strings in registration
44
+ * order, one per body fragment. Each becomes one spine chapter.
45
+ * @param {Object|null} [input.metadata] - Plain data object from the
46
+ * `role: 'metadata'` registration (title, author, language, isbn, …).
47
+ * @param {Object} [options]
48
+ * @param {Object} [options.meta] - Additional metadata merged over the
49
+ * `metadata` registration (options.meta wins).
50
+ * @param {string} [options.stylesheet] - Foundation-supplied CSS string
51
+ * written to `OEBPS/styles.css`. When omitted, DEFAULT_STYLESHEET is used.
52
+ * @param {string} [options.identifier] - EPUB `dc:identifier`. When omitted,
53
+ * a UUID is generated at compile time.
54
+ * @param {string} [options.cover] - URL of the cover image. When set, the
55
+ * fetched bytes are embedded in OEBPS/images/, referenced in the manifest
56
+ * with `properties="cover-image"` (EPUB3) and pointed at by a legacy
57
+ * `<meta name="cover">` hint (Kindle KF8 compatibility).
58
+ * @returns {Promise<Blob>}
59
+ */
60
+ export async function compileEpub(input, options = {}) {
61
+ const { sections = [], metadata = null } = input || {}
62
+ const { meta = {}, stylesheet, identifier, cover } = options
63
+
64
+ const resolvedMeta = { ...(metadata || {}), ...meta }
65
+ const css = stylesheet || DEFAULT_STYLESHEET
66
+ const id = identifier || resolvedMeta.identifier || generateUuid()
67
+ const coverUrl = cover || resolvedMeta.cover || resolvedMeta.coverImage
68
+
69
+ // Walk each HTML fragment, collect image URLs, capture a chapter title.
70
+ // The parsed tree is kept so we can re-serialize as XHTML after
71
+ // rewriting <img src> to manifest-relative paths.
72
+ const chapters = sections.map((html, i) => buildChapter(html, i))
73
+
74
+ // Deduplicate image URLs across all chapters — and the cover, if any —
75
+ // and fetch them in parallel. Failed fetches are logged and the original
76
+ // URL is left in place; the EPUB still opens but may show broken images.
77
+ const imageUrls = new Set()
78
+ for (const ch of chapters) {
79
+ for (const url of ch.images) imageUrls.add(url)
80
+ }
81
+ if (coverUrl) imageUrls.add(coverUrl)
82
+ const fetched = imageUrls.size
83
+ ? await fetchAssets(imageUrls)
84
+ : new Map()
85
+
86
+ // Assign a manifest-relative path to every successfully-fetched image,
87
+ // keyed by URL. Hash-based filenames dedupe identical images byte-for-byte.
88
+ const imagesManifest = []
89
+ const urlToPath = new Map()
90
+ const seenPaths = new Set()
91
+ let coverImageId = null
92
+ for (const [url, result] of fetched) {
93
+ if (result.error) {
94
+ console.warn(`@uniweb/press epub: failed to fetch image ${url}: ${result.error.message}`)
95
+ continue
96
+ }
97
+ const filename = `${result.hash}.${result.ext}`
98
+ const path = `images/${filename}`
99
+ urlToPath.set(url, path)
100
+ const imgId = `img-${result.hash}`
101
+ if (!seenPaths.has(path)) {
102
+ seenPaths.add(path)
103
+ imagesManifest.push({
104
+ id: imgId,
105
+ path,
106
+ mime: result.mime || 'application/octet-stream',
107
+ bytes: result.bytes,
108
+ isCover: url === coverUrl,
109
+ })
110
+ }
111
+ if (url === coverUrl) coverImageId = imgId
112
+ }
113
+
114
+ // Second pass over chapters — rewrite <img src> to manifest paths and
115
+ // serialize each as XHTML. Chapter paths live under `OEBPS/chapters/`
116
+ // so images (under `OEBPS/images/`) resolve via `../images/...`.
117
+ const chapterManifest = chapters.map((ch, i) => {
118
+ rewriteImageSrcs(ch.tree, urlToPath, '../')
119
+ const xhtml = wrapChapterXhtml({
120
+ title: ch.title || resolvedMeta.title || `Chapter ${i + 1}`,
121
+ language: resolvedMeta.language || 'en',
122
+ body: serializeXhtml(ch.tree),
123
+ stylesheetHref: '../styles.css',
124
+ })
125
+ return {
126
+ id: `ch-${pad(i + 1)}`,
127
+ path: `chapters/ch-${pad(i + 1)}.xhtml`,
128
+ title: ch.title || `Chapter ${i + 1}`,
129
+ xhtml,
130
+ }
131
+ })
132
+
133
+ // When a cover image was provided and fetched successfully, synthesize a
134
+ // cover-page XHTML (epub:type="cover") that precedes all content chapters
135
+ // in the spine. Some readers (iBooks, Kobo) show the manifest cover-image
136
+ // only on the shelf; an in-book cover page guarantees readers that don't
137
+ // render a shelf cover still see one when they open the book.
138
+ const coverImage = coverImageId
139
+ ? imagesManifest.find((img) => img.id === coverImageId)
140
+ : null
141
+ const coverChapter = coverImage
142
+ ? {
143
+ id: 'cover-page',
144
+ path: 'chapters/cover.xhtml',
145
+ title: resolvedMeta.coverTitle || 'Cover',
146
+ xhtml: buildCoverXhtml({
147
+ language: resolvedMeta.language || 'en',
148
+ title: resolvedMeta.title || 'Cover',
149
+ imagePath: `../${coverImage.path}`,
150
+ alt: resolvedMeta.coverAlt || resolvedMeta.title || 'Cover',
151
+ }),
152
+ isCover: true,
153
+ }
154
+ : null
155
+ const spineChapters = coverChapter
156
+ ? [coverChapter, ...chapterManifest]
157
+ : chapterManifest
158
+
159
+ // Assemble the ZIP. mimetype must be first and STORED uncompressed —
160
+ // invariant #1. Everything else is DEFLATEd for size; a 200–400KB book
161
+ // typically halves when XHTML/OPF/NCX are deflated. JSZip's default
162
+ // when generateAsync is called without per-file overrides is STORE, so
163
+ // pass compression at the generate level AND keep the per-file STORE
164
+ // on mimetype.
165
+ const zip = new JSZip()
166
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })
167
+ zip.file('META-INF/container.xml', buildContainerXml())
168
+
169
+ const oebps = zip.folder('OEBPS')
170
+ oebps.file('content.opf', buildOpf({
171
+ id,
172
+ meta: resolvedMeta,
173
+ chapters: spineChapters,
174
+ images: imagesManifest,
175
+ coverImageId,
176
+ coverPageId: coverChapter ? coverChapter.id : null,
177
+ }))
178
+ oebps.file('nav.xhtml', buildNav({
179
+ language: resolvedMeta.language || 'en',
180
+ title: resolvedMeta.title || 'Contents',
181
+ chapters: chapterManifest,
182
+ coverPage: coverChapter,
183
+ }))
184
+ oebps.file('toc.ncx', buildNcx({
185
+ id,
186
+ title: resolvedMeta.title || 'Book',
187
+ chapters: spineChapters,
188
+ }))
189
+ oebps.file('styles.css', css)
190
+
191
+ for (const ch of spineChapters) {
192
+ oebps.file(ch.path, ch.xhtml)
193
+ }
194
+ for (const img of imagesManifest) {
195
+ oebps.file(img.path, img.bytes)
196
+ }
197
+
198
+ const blob = await zip.generateAsync({
199
+ type: 'blob',
200
+ mimeType: 'application/epub+zip',
201
+ compression: 'DEFLATE',
202
+ compressionOptions: { level: 6 },
203
+ })
204
+ return blob
205
+ }
206
+
207
+ // ============================================================================
208
+ // Chapter parsing
209
+ // ============================================================================
210
+
211
+ /**
212
+ * Parse one registered HTML string into a chapter descriptor:
213
+ * - tree: parse5 fragment root (we re-serialize it as XHTML later)
214
+ * - title: first heading text, used for nav + <title>
215
+ * - images: set of <img src> URLs found while walking
216
+ */
217
+ function buildChapter(html, index) {
218
+ const tree = parseFragment(html || '')
219
+ const state = { title: '', images: new Set() }
220
+ walkForChapterMeta(tree, state)
221
+ return {
222
+ tree,
223
+ title: state.title,
224
+ images: state.images,
225
+ index,
226
+ }
227
+ }
228
+
229
+ function walkForChapterMeta(node, state) {
230
+ const children = node.childNodes || []
231
+ for (const child of children) {
232
+ if (child.nodeName === '#text') continue
233
+ const tag = (child.tagName || '').toLowerCase()
234
+ if (!state.title && (tag === 'h1' || tag === 'h2' || tag === 'h3')) {
235
+ state.title = collectText(child).trim()
236
+ }
237
+ if (tag === 'img') {
238
+ const src = getAttr(child, 'src')
239
+ if (src && !/^data:/i.test(src)) state.images.add(src)
240
+ }
241
+ if (child.childNodes) walkForChapterMeta(child, state)
242
+ }
243
+ }
244
+
245
+ function rewriteImageSrcs(node, urlToPath, pathPrefix) {
246
+ const children = node.childNodes || []
247
+ for (const child of children) {
248
+ if (child.nodeName === '#text') continue
249
+ const tag = (child.tagName || '').toLowerCase()
250
+ if (tag === 'img') {
251
+ const src = getAttr(child, 'src')
252
+ const rewritten = src && urlToPath.get(src)
253
+ if (rewritten) setAttr(child, 'src', pathPrefix + rewritten)
254
+ }
255
+ if (child.childNodes) rewriteImageSrcs(child, urlToPath, pathPrefix)
256
+ }
257
+ }
258
+
259
+ function collectText(node) {
260
+ if (node.nodeName === '#text') return node.value || ''
261
+ let out = ''
262
+ for (const child of node.childNodes || []) out += collectText(child)
263
+ return out
264
+ }
265
+
266
+ function getAttr(node, name) {
267
+ for (const attr of node.attrs || []) {
268
+ if (attr.name === name) return attr.value
269
+ }
270
+ return null
271
+ }
272
+
273
+ function setAttr(node, name, value) {
274
+ if (!node.attrs) node.attrs = []
275
+ for (const attr of node.attrs) {
276
+ if (attr.name === name) {
277
+ attr.value = value
278
+ return
279
+ }
280
+ }
281
+ node.attrs.push({ name, value })
282
+ }
283
+
284
+ // ============================================================================
285
+ // XHTML serializer
286
+ // ============================================================================
287
+
288
+ /**
289
+ * EPUB3 readers parse chapters as XML, not tag-soup HTML. The serializer
290
+ * emits well-formed XHTML: void elements self-close, attributes are always
291
+ * quoted, text content is entity-escaped, and tag names are lowercase.
292
+ *
293
+ * parse5's built-in serializer emits HTML (`<br>`, not `<br/>`), so we
294
+ * walk the tree manually instead.
295
+ */
296
+ const VOID_ELEMENTS = new Set([
297
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
298
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
299
+ ])
300
+
301
+ export function serializeXhtml(node) {
302
+ if (node.nodeName === '#text') return escapeXmlText(node.value || '')
303
+ if (node.nodeName === '#document-fragment') {
304
+ return (node.childNodes || []).map(serializeXhtml).join('')
305
+ }
306
+ const tag = (node.tagName || '').toLowerCase()
307
+ if (!tag) {
308
+ // Unknown container: spread children.
309
+ return (node.childNodes || []).map(serializeXhtml).join('')
310
+ }
311
+ const attrs = serializeAttrs(node.attrs || [])
312
+ if (VOID_ELEMENTS.has(tag)) {
313
+ return `<${tag}${attrs}/>`
314
+ }
315
+ const inner = (node.childNodes || []).map(serializeXhtml).join('')
316
+ return `<${tag}${attrs}>${inner}</${tag}>`
317
+ }
318
+
319
+ function serializeAttrs(attrs) {
320
+ if (!attrs.length) return ''
321
+ let out = ''
322
+ for (const attr of attrs) {
323
+ const name = attr.name
324
+ const value = attr.value == null ? '' : String(attr.value)
325
+ out += ` ${name}="${escapeXmlAttr(value)}"`
326
+ }
327
+ return out
328
+ }
329
+
330
+ function escapeXmlText(s) {
331
+ return String(s)
332
+ .replace(/&/g, '&amp;')
333
+ .replace(/</g, '&lt;')
334
+ .replace(/>/g, '&gt;')
335
+ }
336
+
337
+ function escapeXmlAttr(s) {
338
+ return String(s)
339
+ .replace(/&/g, '&amp;')
340
+ .replace(/"/g, '&quot;')
341
+ .replace(/</g, '&lt;')
342
+ .replace(/>/g, '&gt;')
343
+ }
344
+
345
+ // ============================================================================
346
+ // EPUB envelope builders
347
+ // ============================================================================
348
+
349
+ function buildContainerXml() {
350
+ return `<?xml version="1.0" encoding="UTF-8"?>
351
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
352
+ <rootfiles>
353
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
354
+ </rootfiles>
355
+ </container>
356
+ `
357
+ }
358
+
359
+ /**
360
+ * EPUB3 package document. Declares metadata, manifest (every file under
361
+ * OEBPS), and spine (ordered reading sequence).
362
+ *
363
+ * Cover handling is intentionally dual: EPUB3 readers pick up the cover
364
+ * from `properties="cover-image"` on the manifest item; Amazon's KF8
365
+ * converter (used by "Send to Kindle" and older kindlegen) still wants a
366
+ * legacy `<meta name="cover" content="<image-id>"/>` hint in metadata.
367
+ * Emitting both is cheap and covers the whole ecosystem.
368
+ */
369
+ export function buildOpf({ id, meta, chapters, images, coverImageId, coverPageId }) {
370
+ const title = meta.title || 'Untitled'
371
+ const language = meta.language || 'en'
372
+ const author = meta.author
373
+ const publisher = meta.publisher
374
+ const description = meta.description
375
+ const subject = meta.subject
376
+ const rights = meta.rights
377
+ const modified = meta.date || new Date().toISOString().slice(0, 19) + 'Z'
378
+
379
+ const metaLines = [
380
+ ` <dc:identifier id="pub-id">${escapeXmlText(id)}</dc:identifier>`,
381
+ ` <dc:title>${escapeXmlText(title)}</dc:title>`,
382
+ ` <dc:language>${escapeXmlText(language)}</dc:language>`,
383
+ ]
384
+ if (author) metaLines.push(` <dc:creator>${escapeXmlText(author)}</dc:creator>`)
385
+ if (publisher) metaLines.push(` <dc:publisher>${escapeXmlText(publisher)}</dc:publisher>`)
386
+ if (description) metaLines.push(` <dc:description>${escapeXmlText(description)}</dc:description>`)
387
+ if (subject) metaLines.push(` <dc:subject>${escapeXmlText(subject)}</dc:subject>`)
388
+ if (rights) metaLines.push(` <dc:rights>${escapeXmlText(rights)}</dc:rights>`)
389
+ metaLines.push(` <meta property="dcterms:modified">${escapeXmlText(modified)}</meta>`)
390
+ // Legacy Kindle/KF8 cover hint — Amazon's converter still reads this.
391
+ if (coverImageId) {
392
+ metaLines.push(
393
+ ` <meta name="cover" content="${escapeXmlAttr(coverImageId)}"/>`,
394
+ )
395
+ }
396
+
397
+ const manifestLines = [
398
+ ` <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
399
+ ` <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`,
400
+ ` <item id="css" href="styles.css" media-type="text/css"/>`,
401
+ ]
402
+ for (const ch of chapters) {
403
+ manifestLines.push(
404
+ ` <item id="${escapeXmlAttr(ch.id)}" href="${escapeXmlAttr(ch.path)}" media-type="application/xhtml+xml"/>`,
405
+ )
406
+ }
407
+ for (const img of images) {
408
+ // EPUB3 cover hint — readers use this to pick the shelf thumbnail.
409
+ const coverProp = img.id === coverImageId ? ' properties="cover-image"' : ''
410
+ manifestLines.push(
411
+ ` <item id="${escapeXmlAttr(img.id)}" href="${escapeXmlAttr(img.path)}" media-type="${escapeXmlAttr(img.mime)}"${coverProp}/>`,
412
+ )
413
+ }
414
+
415
+ const spineLines = chapters.map((ch) => {
416
+ // Cover page should not linger in the reading flow for left-to-right
417
+ // navigation — `linear="no"` lets readers jump straight to content.
418
+ const attrs = ch.id === coverPageId
419
+ ? ` idref="${escapeXmlAttr(ch.id)}" linear="no"`
420
+ : ` idref="${escapeXmlAttr(ch.id)}"`
421
+ return ` <itemref${attrs}/>`
422
+ })
423
+
424
+ return `<?xml version="1.0" encoding="UTF-8"?>
425
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="${escapeXmlAttr(language)}">
426
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
427
+ ${metaLines.join('\n')}
428
+ </metadata>
429
+ <manifest>
430
+ ${manifestLines.join('\n')}
431
+ </manifest>
432
+ <spine toc="ncx">
433
+ ${spineLines.join('\n')}
434
+ </spine>
435
+ </package>
436
+ `
437
+ }
438
+
439
+ /**
440
+ * EPUB3 navigation document. Primary TOC — replaces the NCX for EPUB3
441
+ * readers but NCX is still emitted for EPUB2 compatibility.
442
+ *
443
+ * Two nav elements are emitted:
444
+ * - `epub:type="toc"`: the reading-order chapter list (required).
445
+ * - `epub:type="landmarks"`: quick-jump entries for cover, TOC, and
446
+ * bodymatter start (optional, but assistive-tech readers and many
447
+ * e-readers use it to offer "Go to cover" / "Go to beginning").
448
+ */
449
+ export function buildNav({ language, title, chapters, coverPage }) {
450
+ const items = chapters
451
+ .map(
452
+ (ch) =>
453
+ ` <li><a href="${escapeXmlAttr(ch.path)}">${escapeXmlText(ch.title)}</a></li>`,
454
+ )
455
+ .join('\n')
456
+
457
+ const landmarkLines = []
458
+ if (coverPage) {
459
+ landmarkLines.push(
460
+ ` <li><a epub:type="cover" href="${escapeXmlAttr(coverPage.path)}">Cover</a></li>`,
461
+ )
462
+ }
463
+ landmarkLines.push(` <li><a epub:type="toc" href="nav.xhtml#toc">Table of Contents</a></li>`)
464
+ if (chapters.length) {
465
+ landmarkLines.push(
466
+ ` <li><a epub:type="bodymatter" href="${escapeXmlAttr(chapters[0].path)}">Start of Content</a></li>`,
467
+ )
468
+ }
469
+ const landmarks = ` <nav epub:type="landmarks" id="landmarks" hidden="hidden">
470
+ <h2>Landmarks</h2>
471
+ <ol>
472
+ ${landmarkLines.join('\n')}
473
+ </ol>
474
+ </nav>`
475
+
476
+ return `<?xml version="1.0" encoding="UTF-8"?>
477
+ <!DOCTYPE html>
478
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${escapeXmlAttr(language)}">
479
+ <head>
480
+ <meta charset="utf-8"/>
481
+ <title>${escapeXmlText(title)}</title>
482
+ </head>
483
+ <body>
484
+ <nav epub:type="toc" id="toc">
485
+ <h1>${escapeXmlText(title)}</h1>
486
+ <ol>
487
+ ${items}
488
+ </ol>
489
+ </nav>
490
+ ${landmarks}
491
+ </body>
492
+ </html>
493
+ `
494
+ }
495
+
496
+ /**
497
+ * EPUB2 NCX navigation. Optional in EPUB3 but helpful for older e-readers.
498
+ */
499
+ export function buildNcx({ id, title, chapters }) {
500
+ const navPoints = chapters
501
+ .map(
502
+ (ch, i) => ` <navPoint id="${escapeXmlAttr(ch.id)}" playOrder="${i + 1}">
503
+ <navLabel><text>${escapeXmlText(ch.title)}</text></navLabel>
504
+ <content src="${escapeXmlAttr(ch.path)}"/>
505
+ </navPoint>`,
506
+ )
507
+ .join('\n')
508
+
509
+ return `<?xml version="1.0" encoding="UTF-8"?>
510
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
511
+ <head>
512
+ <meta name="dtb:uid" content="${escapeXmlAttr(id)}"/>
513
+ <meta name="dtb:depth" content="1"/>
514
+ </head>
515
+ <docTitle><text>${escapeXmlText(title)}</text></docTitle>
516
+ <navMap>
517
+ ${navPoints}
518
+ </navMap>
519
+ </ncx>
520
+ `
521
+ }
522
+
523
+ /**
524
+ * Build the cover-page XHTML. Uses `epub:type="cover"` so assistive tech
525
+ * and the landmarks nav can identify it. The image scales to the viewport
526
+ * via inline SVG-style CSS so it works on both phone-sized readers and
527
+ * tablet-sized ones without the foundation needing to override styles.
528
+ */
529
+ export function buildCoverXhtml({ language, title, imagePath, alt }) {
530
+ return `<?xml version="1.0" encoding="UTF-8"?>
531
+ <!DOCTYPE html>
532
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${escapeXmlAttr(language)}">
533
+ <head>
534
+ <meta charset="utf-8"/>
535
+ <title>${escapeXmlText(title)}</title>
536
+ <style type="text/css">
537
+ body { margin: 0; padding: 0; text-align: center; }
538
+ img { max-width: 100%; max-height: 100vh; height: auto; }
539
+ </style>
540
+ </head>
541
+ <body epub:type="cover">
542
+ <div><img src="${escapeXmlAttr(imagePath)}" alt="${escapeXmlAttr(alt)}"/></div>
543
+ </body>
544
+ </html>
545
+ `
546
+ }
547
+
548
+ /**
549
+ * Wrap chapter body HTML in a valid XHTML document.
550
+ */
551
+ export function wrapChapterXhtml({ title, language, body, stylesheetHref }) {
552
+ const css = stylesheetHref
553
+ ? ` <link rel="stylesheet" type="text/css" href="${escapeXmlAttr(stylesheetHref)}"/>\n`
554
+ : ''
555
+ return `<?xml version="1.0" encoding="UTF-8"?>
556
+ <!DOCTYPE html>
557
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${escapeXmlAttr(language)}">
558
+ <head>
559
+ <meta charset="utf-8"/>
560
+ <title>${escapeXmlText(title)}</title>
561
+ ${css} </head>
562
+ <body>
563
+ ${body}
564
+ </body>
565
+ </html>
566
+ `
567
+ }
568
+
569
+ // ============================================================================
570
+ // Helpers
571
+ // ============================================================================
572
+
573
+ function pad(n) {
574
+ return String(n).padStart(2, '0')
575
+ }
576
+
577
+ function generateUuid() {
578
+ if (globalThis.crypto?.randomUUID) {
579
+ return 'urn:uuid:' + globalThis.crypto.randomUUID()
580
+ }
581
+ // RFC 4122 v4-ish fallback — 32 hex chars with dashes at the right places.
582
+ const rand = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0')
583
+ return `urn:uuid:${rand()}${rand()}-${rand()}-4${rand().slice(1)}-a${rand().slice(1)}-${rand()}${rand()}${rand()}`
584
+ }
585
+
586
+ // ============================================================================
587
+ // Defaults
588
+ // ============================================================================
589
+
590
+ export const DEFAULT_STYLESHEET = `/* Minimal EPUB3 stylesheet — foundations can override via options.stylesheet. */
591
+ body {
592
+ font-family: Georgia, "Times New Roman", serif;
593
+ line-height: 1.5;
594
+ margin: 0 5%;
595
+ }
596
+ h1, h2, h3, h4, h5, h6 {
597
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
598
+ line-height: 1.2;
599
+ page-break-after: avoid;
600
+ }
601
+ h1 { font-size: 1.8em; margin-top: 2em; }
602
+ h2 { font-size: 1.4em; margin-top: 1.5em; }
603
+ h3 { font-size: 1.15em; margin-top: 1em; }
604
+ p { margin: 0 0 0.8em; text-indent: 1.25em; }
605
+ p:first-child, p.lead { text-indent: 0; }
606
+ img { max-width: 100%; height: auto; }
607
+ figure { margin: 1em 0; text-align: center; }
608
+ figcaption { font-size: 0.9em; color: #555; }
609
+ blockquote {
610
+ margin: 1em 1.5em;
611
+ padding-left: 1em;
612
+ border-left: 3px solid #ccc;
613
+ color: #444;
614
+ }
615
+ code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em; }
616
+ pre {
617
+ font-family: ui-monospace, Menlo, Consolas, monospace;
618
+ font-size: 0.9em;
619
+ background: #f5f5f5;
620
+ padding: 0.75em;
621
+ overflow: auto;
622
+ white-space: pre-wrap;
623
+ }
624
+ `