postext-epub
v0.19.2
Published
Browser-native EPUB 3 writer for Postext documents: fixed-layout (pre-paginated) books that reproduce every printed page with real text, and reflowable books in semantic XHTML styled from the same configuration.
Maintainers
Readme
postext-epub
EPUB 3 writer for postext.
postext-epub writes a book that postext has laid out as an EPUB 3.3 file, in the browser or in Node, with no server. It reads the same per-chapter VDTDocuments the PDF backend renders, so page numbers, footnotes, citations, cross-references, the contents and the index arrive already resolved. The whole file is built in memory and returned as bytes.
A book comes out in one of the two renditions EPUB 3 defines (the rendition:layout property):
- Fixed layout (
layout: 'fixed', EPUBpre-paginated): every printed page as it is, one XHTML document per page, with real, selectable text placed where the PDF places it. - Reflowable (
layout: 'reflowable', the EPUB default): the text, figures and tables in reading order, as semantic XHTML that the reading system sets again for its screen, typeface and type size.
Right-to-left books (Arabic, Persian, Hebrew…) declare their language and direction on every document and on the navigation, and a right-bound book turns its pages to the left (page-progression-direction="rtl"). In the reflowable rendition a run set against its paragraph (a Latin name in Arabic, an Arabic quotation in English) is a dir isolate, a language named on :ltr[…]{lang=en} is declared, a :::verse poem sets each bayt as two hemistichs side by side, and the tatweels kashida justification inserted are left out (the reading system justifies; tatweels the author typed stay). The fixed layout paints them as printed, out of reach of a text selection.
Website: postext.dev · Docs: EPUB books
Install
npm install postext postext-epubpostext is a peer dependency. Each postext-epub release needs the postext it was released with, or a later one of the same major, so upgrade the two together. Or import both straight from a CDN:
import { buildDocument } from 'https://esm.sh/postext';
import { renderToEpub } from 'https://esm.sh/postext-epub';Quick example
import { buildDocument } from 'postext';
import { renderToEpub } from 'postext-epub';
// Postext measures text with the fonts the browser has loaded.
await document.fonts.load('16px "EB Garamond"');
const doc = buildDocument({ markdown: '# Lantern\n\nTypeset me.' }, {
page: { sizePreset: '17x24' },
bodyText: { fontFamily: 'EB Garamond' },
});
const bytes = await renderToEpub([doc], {
layout: 'reflowable', // or 'fixed'
metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
fonts: [
{ family: 'EB Garamond', weight: 400, style: 'normal', bytes: garamondRegular, format: 'woff2' },
{ family: 'EB Garamond', weight: 400, style: 'italic', bytes: garamondItalic, format: 'woff2' },
],
});
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/epub+zip' }));A whole book
renderToEpub takes the book's chapters in order, each laid out as a continuation of the one before (counters, page parity, page numbers), which is what buildBundle returns for a .postext file:
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes, { locale: 'es' });
const docs = buildBundle(bundle); // one VDTDocument per chapter
const bytes = await renderToEpub(docs, {
layout: 'fixed',
metadata: { title: 'Don Quijote', creators: ['Miguel de Cervantes'], language: 'es' },
fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
resourceBytes: (fileId) => {
const data = bundle.files.get(fileId);
return data ? { bytes: data, mediaType: '' } : undefined; // '' = read the type from the bytes
},
onProgress: ({ phase, done, total }) => console.log(phase, done, total),
onWarning: (w) => console.warn(w),
});The same array is what renderToPdf from postext-pdf takes for a book, so a PDF and an EPUB of one book come from one layout.
API
renderToEpub(docs, options): Promise<Uint8Array>
docs is the book's print documents, one per chapter in book order (at least one). The result is the .epub file.
Without an identifier, the book gets a urn:uuid: derived from its title, creators and language, so writing the same book again keeps its identity in a reader's library. A bare ISBN becomes urn:isbn:…. Pass modified as well for byte-identical output from the same input.
Writing on a worker: createEpubWorker() (postext-epub/worker)
renderToEpub keeps the thread it runs on busy while it writes; a fixed layout of a long book takes a second or more. In a page, write the book on a worker instead:
import { createEpubWorker } from 'postext-epub/worker';
const epubWorker = createEpubWorker();
const controller = new AbortController();
const bytes = await epubWorker.render(docs, {
layout: 'fixed',
metadata: { title: 'My book', language: 'en' },
fonts,
resourceBytes, // a function, as for renderToEpub, or a Map by fileId
onProgress: (p) => console.log(p.phase, p.done, p.total),
onWarning: (w) => console.warn(w),
signal: controller.signal,
});
epubWorker.dispose(); // when the page no longer needs itrender(docs, options) takes the options of renderToEpub and returns the same bytes. What travels to the worker:
- The documents are copied (structured clone).
- A
resourceBytesfunction is asked on the calling thread for every picture the pages place before the worker starts, since the worker cannot reach the host's stores. AMapof{ bytes, mediaType }by file id is sent as it is. - The buffers of the fonts, the pictures and the cover are transferred, not copied, so the caller's views of them are empty afterwards. Pass copies of bytes you keep.
- The finished file comes back transferred.
Progress and warnings are forwarded as they happen. Aborting the signal rejects at once with an AbortError and stops the worker, and the next render starts a fresh one.
The worker script is new URL('./epub.worker.js', import.meta.url), which webpack, Vite and esbuild bundle. Loaded from esm.sh (import { createEpubWorker } from 'https://esm.sh/postext-epub/worker'), the client starts the CDN's postext-epub@<version>/worker/entry itself, through a same-origin blob module, since a worker script must come from the page's origin. createEpubWorker({ url }) names another entry: a copy on your server, or a CDN URL. A host that builds its own worker passes it as createEpubWorker({ worker }), a Worker or a function that makes one. Its script imports postext-epub/worker/entry. A worker that fails to load rejects with an EpubWorkerError, and the host can then call renderToEpub on its own thread.
readEpub(bytes): ReadEpubResult
Reads an EPUB back for a viewer, without DOMParser: the layout, the metadata, the reading direction, every file by its zip path, the manifest, the spine in reading order, the table of contents (from the navigation document, or the NCX of an older file), the page list, the fixed-layout viewport and the cover picture's path. resolveHref(base, href) and dirOf(path) resolve the links between those files.
Lower-level exports
buildFixedPublication and buildReflowablePublication return the publication model (EpubPublication: items, spine, navigation, accessibility metadata) without packing it, and packEpub(publication) writes such a model as a file. bookIdentifier and uuidV5 compute the identifier renderToEpub uses.
What each layout produces
Fixed layout
- One XHTML document per printed page, from the HTML backend's markup for that page. The viewport is the trimmed page in CSS px; the cut-line margin is cropped.
- Text is real text in the embedded fonts, so it can be selected, searched and read aloud. Formulas are inline SVG; pictures are files, each stored once. A ruby base and its reading are a
<ruby>with an<rt>(the reading placed where the print puts it), so assistive technology reads it as the base's reading. page-spread-leftandpage-spread-rightfollow the page parity and the binding. A right-bound book (Arabic, vertical Chinese) hasrtlpage progression, and its pages run right to left.- Links work between pages: cross-references, footnotes, the printed contents and the index. Links to an element the book does not hold are dropped.
- The table of contents comes from the headings and the part pages, the page list from the printed page labels (
iv,12), and the landmarks point at the cover, the printed contents and the start of the body.
Reflowable
- One XHTML document per chapter (
text/chapter-NNN.xhtml). A part opens a document of its own. - Paragraphs are rebuilt from the laid-out lines: the hyphens the line breaks added are removed, hyphens that belong to the word are kept, and a paragraph split across pages or columns is one paragraph again.
- Figures and tables follow the text that cites them, with their numbered captions and alt text. Tables are real tables with header rows and merged cells. Lists nest, boxes become
<aside>with their titles, heading levels never skip, and formulas are SVG labelled with their TeX. - Footnotes link to the notes at the end of the chapter and back. Cross-references, citations and web links work. The printed contents link to the headings, and the index's page numbers link to page markers.
- Each printed page leaves a page-break marker where its text starts, so the page list gives the print page numbers.
- A stylesheet is derived from the configuration: sizes relative to the body text, the book's colours, justification with automatic hyphenation, indents, and styles for headings, boxes, tables (booktabs rules included, and each named table style as a
pt-table-<id>class), captions, lists and notes. Vertical Chinese and Japanese keep vertical writing, and the package names it (primary-writing-mode); Arabic documents aredir="rtl". - Japanese text keeps what the print sets: the kinsoku level as
line-break(ja-very-strict→strict,ja-strict→normal,ja-loose→loose), burasagari ashanging-punctuation: allow-end, the emphasis marks as configured (text-emphasissesame over the text, right of vertical text), short numbers and!?pairs in vertical lines astext-combine-upright, the space after ? and ! as an ideographic space, and the bases of one jukugo reading in one<ruby>. The navigation and landmarks are named in Japanese. - Paragraphs set in a paragraph style (
:::paragraphs{style=…}, a:::versefence's style) carry its class, inside boxes too, and index entries are classed by their level, both as the layout names them (VDTBlock.paragraphStyleId,VDTBlock.indexLevel). Documents laid out by an older engine fall back to telling them from the face and the indent. - The stylesheet follows the first chapter's configuration. A chapter configured otherwise (its own heading styles, say), and every document of a part whose
paletterecolours the book, links a second stylesheet after it (styles/book-2.css…) with only the rules that change; documents that change the same share it. - A pull quote repeats words of the text, so it would be read twice. A box with no title (or a title of marks alone, such as a hanging quotation mark) whose words the chapter's text also has is kept on the page as
<aside epub:type="pullquote" role="doc-pullquote" aria-hidden="true">: readers see it, assistive technology and read-aloud skip it, and the words are read once, in the text. A box whose style id names it a pull quote but whose words are its own is markedepub:type="pullquote"and read.
Both renditions carry the EPUB Accessibility 1.1 metadata (access modes, features such as the table of contents and print page numbers, hazards and a summary), and neither claims WCAG conformance by default.
Limitations
- A part's palette recolours the colours equal to the palette entries it overrides, as the printed text does; two entries that share a colour take the last override.
- A pull quote edited beyond leaving words out (an ellipsis) does not match the text, and is read as well.
- Fonts are embedded as given. Respect the font licences: leave out families that may not be redistributed.
renderToEpubwrites on the calling thread; usecreateEpubWorkerin a page. The writer checks the signal between steps, so a call on the page's own thread stops at the next step, not at once.
Checking output with EPUBCheck
Output is checked with W3C EPUBCheck 5.4. In this repository, with epubcheck on the PATH:
pnpm --filter postext-epub epubcheck # sample books built in the tests
pnpm --filter postext-epub validate # the guide, the showcase presets and Cookbook books, both layouts
node packages/postext-epub/scripts/epubcheck.mjs path/to/book.postext --lang es --layout both --out /tmp/epubepubcheck(src/epubcheck.test.ts) writes the test suite's sample books (fixed, fixed with a cover, reflowable, reflowable with a cover) and fails on any EPUBCheck error or warning. Without the command it is skipped;EPUBCHECK_OUT=dirkeeps the files.scripts/epubcheck.mjslays out one.postextfile or preset folder with its own fonts, writes the EPUB in one or both layouts and runs EPUBCheck on it. Options:--lang,--layout fixed|reflowable|both,--out,--chapters 0,2,--report result.json,--no-cover. For the reflowable book it also compares the letters of the laid-out text with those of the EPUB, so a lost or repeated paragraph shows.scripts/validate.mjsrunsepubcheck.mjson the whole matrix of books and prints a table;--only name,name,--jobs N,--full(all of Hong Lou Meng). It exits with 1 when any book has an EPUBCheck error or warning.
The scripts read this checkout's built dists: run npx tsc in packages/postext, postext-pdf, postext-citeproc and postext-epub first (validate builds postext-epub itself). The usage notes EPUBCheck leaves (CSS-028 for @font-face, OBS-001 for the NCX kept for older readers, HTM_062 for xlink:href in formulas) are informational.
License
MIT
