@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.
- package/README.md +87 -90
- package/package.json +2 -4
- package/src/adapters/dispatch.js +55 -10
- package/src/adapters/docx.js +40 -13
- package/src/adapters/epub.js +624 -0
- package/src/adapters/pagedjs.js +295 -0
- package/src/adapters/typst.js +16 -1
- package/src/assets/fetch.js +155 -0
- package/src/compileRegistrations.js +289 -1
- package/src/index.js +1 -0
- package/src/ir/attributes.js +18 -0
- package/src/ir/compile.js +93 -25
- package/src/useDocumentCompile.js +31 -1
- package/src/vite-plugin-typst.js +7 -3
|
@@ -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, '&')
|
|
333
|
+
.replace(/</g, '<')
|
|
334
|
+
.replace(/>/g, '>')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function escapeXmlAttr(s) {
|
|
338
|
+
return String(s)
|
|
339
|
+
.replace(/&/g, '&')
|
|
340
|
+
.replace(/"/g, '"')
|
|
341
|
+
.replace(/</g, '<')
|
|
342
|
+
.replace(/>/g, '>')
|
|
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
|
+
`
|