# Programmatic usage: HTML, Folio and EPUB

> The laid-out book in a web page, as a 3D book with postext-folio, and as fixed-layout or reflowable EPUB 3 with postext-epub

- HTML version: https://postext.dev/en/docs/configuration-html-folio-epub
- Last updated: 2026-10-16
- Reading time: 16 min
- Other languages: [es](https://postext.dev/es/docs/configuration-html-folio-epub.md), [ca](https://postext.dev/ca/docs/configuration-html-folio-epub.md), [de](https://postext.dev/de/docs/configuration-html-folio-epub.md), [fr](https://postext.dev/fr/docs/configuration-html-folio-epub.md), [it](https://postext.dev/it/docs/configuration-html-folio-epub.md), [pt](https://postext.dev/pt/docs/configuration-html-folio-epub.md), [zh](https://postext.dev/zh/docs/configuration-html-folio-epub.md), [ja](https://postext.dev/ja/docs/configuration-html-folio-epub.md), [ar](https://postext.dev/ar/docs/configuration-html-folio-epub.md)

## In short

This page is for people who write code and want to show a book on a screen. The first section puts the pages inside a web page and explains how that view differs from the canvas and the PDF. The second shows the book in 3D, lying open on a desk, with pages the reader turns. The last section makes an EPUB e-book, with fixed pages or with text that flows to fit the screen, and checks the file.

## Integrating the HTML viewer

The HTML viewer is Postext's screen-first renderer. Instead of rasterising pages to a bitmap it emits absolutely-positioned DOM nodes whose geometry is driven by the same pipeline that produces print output. This makes it the right choice when you want readable, selectable, and resize-aware typography in a browser — a reading app, an in-product preview, or an embedded docs surface — without pulling in a PDF viewer.

The key pieces from the public API:

- **`buildDocument(content, config, cache?)`** — runs the full layout pipeline and returns a `VDTDocument`.
- **`renderToHtmlIndexed(doc, options)`** — turns the VDT into a single HTML string plus a per-page / per-block breakdown. The breakdown enables cheap DOM patching when only a few blocks changed between renders.
- **`resolveHtmlViewerConfig(partial)`** — fills in the HTML-viewer defaults (`maxCharsPerLine`, `columnGap`, `optimalLineBreaking`).
- **`buildFontString` + `measureGlyphWidth` + `dimensionToPx`** — measurement primitives used to derive an actual pixel column width from a target character count.
- **`createMeasurementCache` / `clearMeasurementCache`** — pluggable caches so you can reuse measurements across re-layouts.
- **`prepareFonts` / `buildDocumentWithFonts` / `watchFonts` / `onFontsChanged`** — load the document's faces before layout and lay out again when faces arrive (see [Loading fonts before layout](https://postext.dev/en/docs/configuration-programmatic-usage.md#loading-fonts-before-layout)).

### Live example: an HTML string

The whole round trip in plain JavaScript, before the React integration below: build the document, hand the `VDTDocument` to `renderToHtml`, and drop the string into a container. `mode: 'single'` stacks the pages vertically; `background` gives them a colour, since pages are transparent by default. The pen also prints the generated markup, so you can see the absolutely positioned lines the renderer emits — the browser paints them but never reflows them.

> **Runnable example: Postext · render a document to HTML** — Lay out a markdown document with postext and render it to an HTML string. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/render-html))

### Minimal integration

The snippet below is the shortest useful integration: build the document at the current viewport size, render it into a container, and re-run on resize.

```tsx
import { useEffect, useRef } from 'react';
import {
  buildDocument,
  renderToHtmlIndexed,
  resolveHtmlViewerConfig,
  buildFontString,
  measureGlyphWidth,
  dimensionToPx,
  createMeasurementCache,
  watchFonts,
  onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';

// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;

// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
  'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';

function sampleForChars(n: number): string {
  let s = SAMPLE;
  while (s.length < n) s += ' ' + SAMPLE;
  return s.slice(0, n);
}

export function PostextHtmlViewer({
  markdown,
  config,
  mode = 'multi',
}: {
  markdown: string;
  config: PostextConfig;
  mode?: 'single' | 'multi';
}) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const cacheRef = useRef<MeasurementCache>(createMeasurementCache());

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const relayout = () => {
      const rect = host.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;

      const viewer = resolveHtmlViewerConfig(config.htmlViewer);
      const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
      const fontWeight = config.bodyText?.fontWeight ?? 400;
      const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
      const fontSizePx = dimensionToPx(fontSize, HTML_DPI);

      // Measure the *actual* column width for N characters of body prose.
      const targetColumnPx = measureGlyphWidth(
        sampleForChars(viewer.maxCharsPerLine),
        buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
      );

      const inner = Math.max(rect.width - PADDING_PX * 2, 100);
      let columnWidthPx: number;
      if (mode === 'single') {
        columnWidthPx = Math.min(targetColumnPx, inner);
      } else {
        // Fit as many columns as we can at the target width.
        const count = Math.max(
          1,
          Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
        );
        columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
      }
      columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);

      // Single mode uses one very tall page; multi mode uses the viewport
      // height so each VDT "page" becomes one column.
      const pageHeightPx =
        mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);

      const override: PostextConfig = {
        ...config,
        page: {
          ...config.page,
          dpi: HTML_DPI,
          width: { value: columnWidthPx, unit: 'px' },
          height: { value: pageHeightPx, unit: 'px' },
          margins: {
            top: { value: 0, unit: 'px' },
            bottom: { value: 0, unit: 'px' },
            left: { value: 0, unit: 'px' },
            right: { value: 0, unit: 'px' },
          },
        },
        layout: { ...config.layout, layoutType: 'single' },
        bodyText: {
          ...config.bodyText,
          optimalLineBreaking: viewer.optimalLineBreaking,
        },
      };

      const doc = buildDocument({ markdown }, override, cacheRef.current);
      const { html } = renderToHtmlIndexed(doc, {
        mode,
        columnGap: viewer.columnGap,
        padding: PADDING_PX,
        background: 'transparent',
      });

      host.innerHTML = html;
    };

    relayout();

    const ro = new ResizeObserver(() => relayout());
    ro.observe(host);

    // Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
    const stopWatching = watchFonts();
    const off = onFontsChanged(() => relayout());

    return () => {
      ro.disconnect();
      off();
      stopWatching();
    };
  }, [markdown, config, mode]);

  return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}
```

A few notes on what this example is doing:

- **Measuring the column, not approximating it.** Because `maxCharsPerLine` is a *target* expressed in characters, the actual pixel width depends on the body font. `measureGlyphWidth` gives a real measurement against the chosen font, which keeps the measure consistent across font swaps.
- **Rewriting the page.** The HTML viewer treats each VDT "page" as one on-screen column. The example overrides `page.width` with the measured column width, sets margins to zero (the padding lives outside the page in the wrapping `.pt-doc` div), and uses `HTML_DPI = 144` so `8pt` body text resolves to `16px`.
- **Font-loading awareness.** `watchFonts` listens to `document.fonts` and, once a frame, drops what was measured in the families whose faces arrived; `onFontsChanged` then lays the column out again. Without the relayout, the first render uses a fallback font's metrics and jumps when the real font lands.
- **Reusing the measurement cache.** Creating the cache once per component means resizes and font-scale changes reuse measurements from the previous render instead of re-measuring every paragraph.

### Going further

The example above is intentionally flat. Production integrations usually add:

- **Shadow DOM isolation** — render into `host.attachShadow({ mode: 'open' })` so nothing in the outer page can bleed CSS into the viewer.
- **Incremental patching** — `renderToHtmlIndexed` returns `pages[i].blocks`, each with a stable `id` and the block's outer HTML. When only a few blocks differ between two renders you can replace those block wrappers in place instead of rebuilding `innerHTML`.
- **Overlays** — layering an absolutely-positioned SVG on top of each `.pt-page` for cursors, selections, or baseline grids.
- **Links** — the words of a Markdown link are wrapped in `<a href="…" rel="noopener noreferrer">`, which takes the text colour and has no underline; see [Document format › Links](https://postext.dev/en/docs/document-format-inline.md#links). In an editor-like viewer, intercept clicks on `a[href]` that do not start with `#` and open them in a new tab (`:ref` anchors link within the document).
- **Single-ink pictures** — with `diagramStyle.singleInk` on, SVG `<img>`s get a CSS filter unless you pass `singleInk: false` for URLs that are already recoloured; see [Single ink on canvas and in HTML](https://postext.dev/en/docs/configuration-resources.md#single-ink-on-canvas-and-in-html).

The sandbox's `HtmlPreview` component (`packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx`) implements all of these on top of the same API shown here and can be used as a reference. It also routes every build through a shared layout worker (see [Running layout in a Web Worker](https://postext.dev/en/docs/configuration-programmatic-usage.md#running-layout-in-a-web-worker)) so that live edits and resizes never block the main thread — swap the direct `buildDocument(...)` call in the snippet above for `layoutWorker.build(...)` when you are ready to move layout off the main thread.

### How the HTML output differs from canvas and PDF

`renderToHtml` places every line, figure and design element exactly where the canvas and the PDF do, but it paints less around them:

| Feature | Canvas (`renderPage`) | HTML (`renderToHtml`) | PDF (`renderToPdf`) |
| --- | --- | --- | --- |
| Page background | White, with `page.backgroundColor` over the trim and bleed. | **Transparent**, unless you pass `background` or set `page.backgroundColor` (which then fills the whole page box, slug included). | White, with `page.backgroundColor` over the trim and bleed. |
| Baseline grid (`page.baselineGrid`) | Drawn | Not drawn | Drawn |
| Column rule (`layout.columnRule`) | Drawn | Not drawn | Drawn |
| Cut marks (`page.cutLines`) | Drawn | Not drawn; the page box still includes the slug around the trim. | Drawn |
| Page negative | `pageNegative` option | Not available | `pageNegative` option |
| Text | Pixels | Selectable text in absolutely positioned elements, set in the CSS font families: the page must load the same faces. | Embedded fonts from your `fontProvider`; selectable, searchable and tagged. |
| Vertical text (`layout.writingMode: 'vertical-rl'`) | Characters painted one cell at a time, turned back upright; vertical forms through a twin face (`loadVerticalAlternates`). | The flow in one box turned a quarter turn; each line turned back upright and set with `writing-mode: vertical-rl`, so the browser takes the vertical forms and stands the characters; short numbers in `text-combine-upright: all`; a dash, an ellipsis, an interpunct or a wave dash in a box of its cell (the browser would advance it by its horizontal width), a dash stretched to fill it by `flow.dashAdvances`. | Upright characters through an `Identity-V` twin of each font; see [Vertical text in the PDF](https://postext.dev/en/docs/configuration-pdf-output.md#vertical-text-in-the-pdf). |
| Images | `registerResourceImage` | The `resourceImageUrl(fileId)` option; a grey placeholder box without it. | The `resourceBytes(fileId)` option. |
| Formulas | Vector paths | Inline `<svg>` | Vector paths |
| Links | None | `:ref` citations link to their resource; contents rows do not. | `:ref` citations and contents rows, plus the outline (bookmarks). |

The transparent page matters on a dark site: a preview without `background` shows black text on the site's dark background. Pass `renderToHtml(doc, { background: '#ffffff' })`, or give the document a `page.backgroundColor`.

**The host page's text styles stay out.** Every line is set at the widths the engine measured, so a `letter-spacing`, `word-spacing`, `text-transform` or `font-variant` that the output inherited from the page around it would widen the glyph runs and make the lines overprint. The `.pt-doc` root therefore resets the inherited text properties — letter and word spacing, case, indent, white space, font style, variant, weight, stretch, features and kerning, line height, alignment, text shadow and emphasis, hyphens, direction, writing mode, text stroke and fill, and mobile text inflation — before its own layout declarations, so the output looks the same inside a shadow root or under a styled element. The list is exported as `HTML_TEXT_RESET`, a string of CSS declarations: a host that mounts the pages' `innerHtml` (from `renderToHtmlIndexed`) in containers of its own sets it on their root. Up to postext 1.4 the root reset nothing; the workaround was a wrapper with `all: initial`.

## A 3D book (`postext-folio`)

`postext-folio` sets a laid-out document on the screen as a printed book lying open on a desk: spreads by the recto rule, and leaves the reader turns with the ‹ › buttons, the arrow keys, a swipe, a click on a page, or by taking a page by its edge and dragging it over. Each leaf curls in three.js according to its paper and casts a real shadow on the pages under it. The WebGL canvas draws the book still and turning alike, so a page never changes look when it lands. It is the viewer of the Cookbook's recipes and of the sandbox's **Folio** tab.

```bash
npm install postext postext-folio three
```

```ts
import { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';

const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
  onChange: ({ pages }) => console.log('showing pages', pages),
});

// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));
```

- **Pages are painted as they are needed.** `createFolioFromDocument` paints each page with `renderPageToCanvas` at exactly the device pixels of a page slot (WebGL then shows it texel for pixel, as sharp as the canvas preview), and only the spreads around the open one (`window`, three either side by default). Pages that fall out of that window are freed, so a book of a thousand pages costs the memory of a few. A jump to a far page paints that spread first. Up to ten pages away the leaves turn one by one; farther, the block of pages in between lifts as one slab, as thick as those pages (their calipers summed), and lands on the other side. `setDocument` keeps the painting of every page that reads the same in the new layout (`{ repaint: true }` paints them all again, after an image came in).
- **The document sets the book.** Its first page opens alone on the right when it is a recto (`pageIndexOffset` even), a right-bound book (`page.binding: 'right'`, or a vertical document) lies mirrored and turns leftward, blank pages take the page's background colour, and the page's trim width (`pageWidthMm`) scales the paper's caliper and the boards. A chapter laid out with a `continuation` counts the book's other pages (`pageIndexOffset` before it, `bookPageCount` after it) into the thickness of the two page blocks without drawing them (`extraPages`).
- **The document sets the look.** The paper, the binding, the desk and the light are the document's [`folio` settings](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#folio-viewer-config) (`doc.config.folio`). A page set inside a `:::paper` run carries its own stock (`VDTPage.paper`), and its leaf is drawn with that paper's colour, surface, thickness and stiffness. With `binding.cover: 'pages'` the first page is the front board and the last, when it is a verso, the back board (`covers`). A newspaper trim (`'broadsheet'`, `'berliner'`, `'tabloid'`, `'compact'`) whose settings name no stock and no binding lies as folded newsprint, also when the host passes its own `folio`.
- **The container sets the size.** The book fills it, with the buttons and the page count in the margins, so give the container a height; a resize paints the pages again at the new size. Below 560 px wide it shows one page at a time (`mode: 'auto'`; `'single'` and `'double'` force either): the spine runs along the page's inner edge and the leaf turns over it, a drag towards the spine turns forward, a swipe away from it goes back, a tap turns.
- **What the pointer does.** `interaction` (and `setInteraction` later) sets what the left button, one finger or a pen does on the book: `'hand'` (the default) takes and turns the pages, `'orbit'` turns the view as a right-drag does (for trackpads and tablets), `'select'` leaves the pointer to the host, to select text, say. `pageAt(event)` gives the page under a pointer and where on it (`{ page, x, y }`, fractions of the page from its top left corner), on the book as it is seen, tilted or orbited; `pointOnScreen(point)` goes the other way, for drawing a caret or a selection over the page. `refreshPage(src)` shows again a page canvas the host drew anew in place. The sandbox uses all of them to select text and follow the editor's caret on the 3D pages.
- **A magnifying glass for small print.** `interaction: 'magnify'` holds a round glass with a black rim over the book where the pointer is (a finger holds it above itself while it touches the screen). It shows the book as the reader's eye sees it, lit and curved as it lies, magnified most at the centre and curving in towards the rim. The wheel, `+` and `−` change how much it magnifies (`setMagnification(zoom)`, 1.5 to 10; the default shows the page at about 5.5 CSS px a millimetre) and Esc puts it away; `magnifier: { zoom, diameter }` sets both from the start. `createFolioFromDocument` paints the pages under the glass again, sharp enough for its centre, so a newspaper's body type reads; `createFolio` takes such paintings from `detail: { paint(index, deviceWidth), release() }`. The sandbox puts it on the **Magnifying glass** button (M). The glass also selects text: over the pages the cursor is a text cursor, `pageAt` returns the point under the glass's centre (above the finger on a touch screen), and in the Sandbox a click there places the caret and a drag selects.
- **The reader can look round it.** A right-drag orbits the view round the book (down to 70° from overhead), also while leaves are turning; `resetView()` eases it back to the settings' `tilt` and `yaw`, and `getView()` gives the view as it is seen now (`{ tilt, yaw }`, degrees) to store as those settings. The sandbox puts them on two buttons, **Reset view** and **Save as default view**.
- **Videos play on the pages.** A click on a video's poster plays it on the page, in every `interaction` mode, and it keeps playing while its leaf turns; another click pauses it, and it stops when the book comes to rest on a spread that does not show it. A video with `player.autoplay` starts by itself the first time its spread is shown; one that also plays alongside the others (`player.exclusive: false`) starts, muted, each time and stops when the spread is turned away, several at once. Options `videos`, `videoUrl` and `onVideo`, and `stopVideo()` on the viewer; see [Document format › Videos on Folio's pages](https://postext.dev/en/docs/document-format-resources.md#videos-on-folios-pages).
- **Fonts and images first.** As for `renderPage`, the faces the document uses must be loaded in `document.fonts` and its resource images registered with `registerResourceImage` before pages are painted.
- **Accessible.** The viewer is a focusable group that takes ←/→ (mirrored for a right-bound book), Page Up/Down, Home and End; its buttons and page count are labelled (`labels` translates them), and each page canvas carries `alt` text (`alt: (index) => …`).
- **Without WebGL2**, or when the reader asks for reduced motion, the spreads simply change. WebGL books are heavy for a phone (a texture per page side): the sandbox offers its Folio tab only where WebGL2 is available and the screen's short side is at least 600 px. `canFlip()` says whether the leaves will turn in 3D here: WebGL2, and no reduced motion.

### Appearance

The `appearance` option, and `setAppearance` later, override what the document says. Anything left out keeps the document's:

```ts
const book = createFolioFromDocument(container, doc, {
  appearance: {
    folio: {
      tilt: 22,
      paper: { type: 'bookWove', texture: 'laid' },
      binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
      surface: { type: 'walnut' },
      lighting: { environment: 'lamp' },
    },
    // Photographed desks: a folder laid out as postext.dev's /folio/textures/
    // (manifest.json and one folder per desk). Without it, procedural maps.
    textureBaseUrl: '/folio/textures',
    // The picture for `folio.binding.spineImage`: the resource's URL,
    // or a drawn canvas or image.
    spineImage: spineUrl,
  },
});

// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();
```

| Field | What it does |
| --- | --- |
| `folio` | The [`folio` settings](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#folio-viewer-config): tilt, paper, binding, surface, lighting. Replaces the document's when given. |
| `pageWidthMm` | The trim width of a page in mm, against which the caliper and the boards are scaled. From the document: the trimmed page at its dpi. Default 150 for `createFolio`. |
| `extraPages` | `{ before, after }`: pages of the book outside the ones given, counted for the thickness of the page blocks and never drawn. |
| `covers` | `{ front, back }`: the first page given is the front cover, the last the back cover (when it falls on a verso). They turn as boards and no case is drawn. From the document: `binding.cover: 'pages'` on a book that starts at its first page and ends at its last. |
| `spineImage` | The picture printed on the spine, as a URL, a canvas or an image. `createFolioFromDocument` does not look resources up: pass the picture of the resource `folio.binding.spineImage` names. Ignored on a saddle stitch. |
| `textureBaseUrl` | Where the photographed desk textures are served. Until they load, or without it, the desk is drawn with procedural maps. |

`createFolio(container, { pages })` is the same viewer over any pages: image URLs, `<img>` or `<canvas>` elements, and `""` for a blank page; a page can be `{ src, alt, paper }`, with `paper` a `:::paper`-style stock for that leaf. `PageFlipper` is the three.js engine alone, for a host that lays out its own spread DOM; `FlatPageFlipper` is the flat page-turn that predates the 3D book, kept for the Cookbook's light table. The full option list is in the [package README](https://www.npmjs.com/package/postext-folio).

### Live example: a book in 3D

The pen imports `postext` and `postext-folio` from a CDN, lays a short document out and opens it as a book. Take the right page by its edge and drag it over.

> **Runnable example: Postext · a document as a 3D book** — Lay out a markdown document with postext and turn its pages in 3D with postext-folio. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/render-folio))

### Live example: page images

`createFolio` with pages drawn on canvases, a blank last page and the paper colour.

> **Runnable example: Postext · a 3D book of images** — Turn any pages in 3D with postext-folio: image URLs, img or canvas elements. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/folio-images))

## EPUB books (`postext-epub`)

`postext-epub` writes a laid-out book as an EPUB 3.3 file, in the browser or in Node, with no server. It reads the same chapter documents `renderToPdf` takes for a book, so page numbers, notes, citations, cross-references, the contents and the index arrive resolved, and it returns the file as bytes. It is the writer behind the sandbox's **EPUB 3** tab.

```bash
npm install postext postext-epub
```

`postext` is a peer dependency, as for `postext-pdf`: upgrade the two together, and on a CDN pin them to the same version.

### Fixed layout and reflowable

EPUB 3 defines two renditions, set by the package's `rendition:layout` property; `layout` picks one:

|  | `layout: 'fixed'` | `layout: 'reflowable'` |
| --- | --- | --- |
| EPUB name | `pre-paginated` (fixed layout, FXL) | `reflowable`, the EPUB default |
| Content documents | One XHTML document per printed page, at the trimmed page size in CSS px | One XHTML document per chapter (a part opens one of its own) |
| What it keeps | The page: columns, floats, running heads, openers, line breaks and positions, in the embedded fonts. Text stays real text: selectable, searchable, read aloud | The text and its structure: headings, paragraphs rebuilt from the lines, lists, boxes as asides, figures and tables after the text that cites them, notes, links, print page markers. A style sheet derived from the configuration |
| What it gives up | The reader's choice of typeface, size and margins; on a small screen the page is scaled down | Columns, running heads, page design and the exact line breaks |
| Spreads and direction | `page-spread-left` / `page-spread-right` from parity and binding; a right-bound book reads right to left | Reading direction from the binding; vertical Chinese keeps `vertical-rl`, Arabic is `dir="rtl"` |
| Suited to | Designed pages: illustrated books, textbooks, catalogues, magazines; large screens | Running text: novels, essays, reports; phones and e-ink readers |

Both renditions carry the same navigation: a table of contents from the headings and part pages, a page list with the printed page labels, landmarks (cover, printed contents, start of the body) and an NCX for older reading systems.

### Writing a book

```ts
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';

const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order

const bytes = await renderToEpub(docs, {
  layout: 'reflowable',
  metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
  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;
  },
  onWarning: (w) => console.warn(w),
});
```

A single document is a book of one chapter: `renderToEpub([doc], options)`.

- **`renderToEpub(docs, options): Promise<Uint8Array>`** writes the file. `options` is `{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }`.
- **`metadata`**: `title` and `language` (a BCP 47 tag) are required; `subtitle`, `creators`, `identifier`, `date`, `publisher`, `rights`, `description` and `modified` are optional. A bare ISBN becomes `urn:isbn:…`. Without an `identifier` the book gets a `urn:uuid:` derived from its title, creators and language, so a new version of the same book keeps its place in a reader's library. Pass `modified` too for byte-identical output.
- **`fonts`**: the faces to embed, `{ family, weight, style, bytes, format, unicodeRange? }` with `format` one of `woff2`, `woff`, `ttf`, `otf`. Each face becomes a file and an `@font-face` rule; several files with their `unicodeRange` make one face (Google Fonts slices). A family, weight or style the pages use with no embedded face is reported as `missingFont`, and reading systems substitute their own. Embed only fonts whose licence allows it: a face with `redistributable: false` is never written into the file (the book may be set with it, but its file stays out, also of SVG pictures).
- **`resourceBytes(fileId)`**: the pictures the pages place, sync or async, as `{ bytes, mediaType }`; an empty `mediaType` is read from the bytes. Give bitmaps as stored and SVGs as their source, not the PDF print master (`svg.pdfFileId`). Each picture is stored once. A single-ink book (`diagramStyle.singleInk`) has its SVGs recoloured in the file. An SVG gets the faces its text names embedded in it, since a reading system shows it as an image that cannot see the book's fonts (see [Fonts in SVG text](https://postext.dev/en/docs/configuration-resources.md#fonts-in-svg-text)): from `fonts` (the slices that hold its characters), then from **`svgFonts.provider`** for a family the book's text does not use. Families of faces marked `redistributable: false`, and those `svgFonts.withhold(family)` names, stay out and are reported once each as `fontWithheld`; a family with no face is reported as `svgFontUnavailable`, faces over `svgFonts.maxBytes` (2 MiB) as `svgFontsTooLarge`. `svgFonts.inline: false`, `diagramStyle.inlineFonts: false` and a resource's `svg.inlineFonts: false` keep the bytes as given. A picture with no bytes is reported as `missingImage` and left as an empty frame.
- **`cover`**: `{ bytes, mediaType, alt? }`, a JPEG, PNG, WebP or SVG picture. The book then opens on a cover document holding it, and it is the package's `cover-image` (the thumbnail in a library). Without one, the fixed layout names its first page as the cover and the reflowable book has no cover picture.
- **`onProgress({ phase, done, total })`**: `resources` (fonts and pictures), `documents` (pages for a fixed layout, chapters for a reflowable one), then `package`. **`signal`** aborts between steps.
- **`readEpub(bytes)`** reads a file back for a viewer, without `DOMParser`: layout, metadata, reading direction, every file by path, the manifest, the spine, the table of contents, the page list, the fixed-layout viewport and the cover. The sandbox's reader is built on it.

Both renditions carry EPUB Accessibility 1.1 metadata (access modes, features such as the table of contents and print page numbers, hazards and a summary) and make no WCAG conformance claim by default. The full option list and the limitations are in the [package README](https://www.npmjs.com/package/postext-epub).

### Checking a file with EPUBCheck

[W3C EPUBCheck](https://www.w3.org/publishing/epubcheck/) is the reference validator for EPUB; ebook stores check the files they receive with it. With it installed (`brew install epubcheck`, or the Java release), `epubcheck book.epub` lists errors, warnings and usage notes; the usage notes Postext output leaves (`CSS-028`, `OBS-001`, `HTM_062`) are informational. In the Postext repository, `pnpm --filter postext-epub epubcheck` checks the test suite's sample books, `node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both` lays out one `.postext` file or preset folder and checks both renditions, and `pnpm --filter postext-epub validate` runs the whole matrix of books (the guide, the showcase presets, Chinese, Arabic and Cookbook books), each of which passes with no errors or warnings.
