# Preliminares con folios romanos y luego la página 1

> La cubierta y los preliminares son títulos sin numerar contados en romanos; :::numbering vuelve a contar desde 1 en la página impar donde empieza la novela.

- Versión HTML: https://postext.dev/es/cookbook/front-matter-roman-to-arabic
- Receta N.º 006 · Estructura del libro · Nivel 3 (Avanzado) · Salidas: Canvas, PDF
- Géneros: Narrativa, teatro y prosa literaria
- Requiere postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · probada con 1.4.1, postext-pdf 1.4.1 el 2026-09-25
- Páginas: [i](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p01.webp?v=d647fa52), [iii](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p03.webp?v=d647fa52), [iv](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p04.webp?v=d647fa52), [v](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p05.webp?v=d647fa52), [vi](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p06.webp?v=d647fa52), [vii](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p07.webp?v=d647fa52), [viii](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p08.webp?v=d647fa52), [ix](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p09.webp?v=d647fa52), [x](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p10.webp?v=d647fa52), [1](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p11.webp?v=d647fa52), [3](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p13.webp?v=d647fa52), [4](https://postext.dev/cookbook/front-matter-roman-to-arabic/en/p14.webp?v=d647fa52)
- PDF: https://postext.dev/cookbook/front-matter-roman-to-arabic/en/front-matter-roman-to-arabic.pdf?v=d647fa52
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/front-matter-roman-to-arabic.md)

## Lo que vas a componer

Las primeras hojas de *Frankenstein* en una edición de bolsillo de 129 × 198 mm: una cubierta con un rayo sobre el Mont Blanc y el dorso en blanco, la anteportada, un frontispicio con el barco de Walton atrapado en el hielo frente a la portada, los créditos al pie de su reverso, la dedicatoria a Godwin sobre el epígrafe de Milton, el índice y el prólogo de 1818. Esas diez páginas se cuentan de la i a la x, aunque solo el índice y el prólogo llevan número. La novela empieza con la Carta I en página impar, bajo la aurora boreal, y su folio, 1, ocupa el sitio de la x de enfrente. La carta sigue hasta la firma de Walton. En el índice, la ix del prólogo y la 1 de la carta salen de las páginas compuestas, y un visor de PDF numera igual.

**Esta receta responde a:**

- ¿Cómo numero los preliminares i, ii, iii y vuelvo a contar desde 1 en la página impar donde empieza la novela?
- ¿Cómo hago una cubierta, una portadilla, una portada y un colofón?
- ¿Cómo añado un índice que se actualice solo (líneas de puntos, números de página, autores, filas de parte)?
- ¿Cómo inserto páginas en blanco a propósito, o empiezo una sección en una doble página nueva?
- ¿Cómo compongo un epígrafe, una dedicatoria, una firma o una cita destacada con una comilla grande?

## La respuesta corta

```js
// script.js, líneas 61–83
// The page counter starts in lower-case roman: the cover is page i. After the Preface, two
// directives in the Markdown count again from 1 on the next recto:
//   :::pagebreak{parity="odd"}
//   :::numbering{format="decimal" startAt=1}
// :::numbering takes effect on the next page that starts (gotcha: numbering-next-page). Level 1
// breaks to 'odd', so here the pagebreak changes nothing; it finds the recto when a level breaks
// with parity 'any' or not at all. Never with 'always-odd': the blank pages would stack up.
const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
// One folio design, centred under the text block. {pageNumber} prints the page's own label,
// so the same design sets "x" in the front matter and "1" on the first page of the novel.
const folio = (pages) => text(`folio-${pages}`, '{pageNumber}', 'Playfair Display SC', 8.5,
  at('top', 0, 9), { letterSpacing: pt(0.8), pages }); // lower case sets as small capitals
// The front matter is a heading style, unnumbered and left out of the contents (a heading
// can opt back in with {toc="true"}). It prints no running heads and puts a folio on every
// page of the section. Parity 'any', because the contents fall on a verso.
const front = {
  id: 'front', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  header: { elements: [] }, footer: { elements: [folio('all')] }, ...frontLook,
};
// The novel keeps the document's furniture: heads on its body pages, the folio on openers.
const footer = { elements: [folio('opener')] };
// Hook-up in config(): page: { pageNumbering }, headingStyles: [front] and footer at the top
// level. frontLook, set above, is the look of the prelims' openers.
```

## Ingredientes

**Enseña**

- [Preliminares en romanos](https://postext.dev/es/docs/document-format.md#numbering): Números de página en romanos para los preliminares, que vuelven a 1 con el primer capítulo, y las mismas etiquetas de página en el PDF.
- [Índice de contenidos](https://postext.dev/es/docs/configuration.md#índice-de-contenidos): Un :::toc generado a partir de los títulos: líneas de puntos, números de página tal como se imprimen, líneas de autor, filas de parte en color y entradas con enlace en el PDF.
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado): Un estilo de título lleva su propia cabecera y su propio pie, así que los preliminares, las secciones o las letras de un diccionario tienen los suyos.

**También usa**

- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Geometría por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Marcadores del PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf-configuración)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`toc`](https://postext.dev/es/docs/configuration.md#índice-de-contenidos)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

- Fanwood Text (OFL-1.1), Playfair Display SC (OFL-1.1), Cinzel (OFL-1.1)

## Elaboración

### 1 · Cada hoja es un título con diseño

```js
// script.js, líneas 87–154
// Each leaf is a heading, so it opens its page, gets a PDF bookmark and takes a design. The
// leaves are unnumbered, left out of the contents and print no heads or folio (blind folios).
// Parity 'any', or each would inherit level 1's odd break (gotcha: style-inherits-break).
const blind = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  header: { elements: [] }, footer: { elements: [] } };
const PLATE_H = 126; // mm: the frontispiece, as wide as the text block; its caption hangs below
// mm from the trim: the imprint's eleven lines end on the text block's last baseline.
const IMPRINT_TOP = 116.5; // re-tune it whenever the imprint changes
const leaves = [
  // i: bled to the trim. Type below the text block's foot would drop the heading's reserve.
  { id: 'cover', ...blind, advancedDesign: design([
    image('cover', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
    label('author', '{author}', 9, onPage(22), 'bone', 2.6),
    text('title', '{title}', 'Fanwood Text', 56, onPage(31), { color: col('bone'), lineHeight: 1 }),
    text('subtitle', '{subtitle}', 'Fanwood Text', 13, onPage(53), { ...on('ice'), italic: true }),
    label('series', '{attr.publisher}', 7, onPage(168), 'ice', 2),
  ]) },
  // iii: the half title waits for a recto, so the back of the cover, ii, is left blank.
  { id: 'half', ...blind, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: design([
    text('title', '{title}', 'Playfair Display SC', 17, at('top', 0, 34), { letterSpacing: pt(3) }),
    image('crystal', at('top', 0, 45, { width: 6 })),
  ]) },
  { id: 'plate', ...blind, advancedDesign: design([ // iv: faces the title page
    image('plate', at('top-left', 0, 0, { width: 'fill' })),
    text('caption', '{attr.caption}', 'Fanwood Text', 8.8,
      at('top', 0, PLATE_H + 5, { width: 80 }), { italic: true, lineHeight: 1.3 }),
    label('source', '{attr.source}', 7.5, at('top', 0, PLATE_H + 16), 'muted', 1.3),
  ]) },
  { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
    // {publishDate} prints because the year is quoted (gotcha: quote-frontmatter).
    label('edition', 'The text of {publishDate}', 7.5, at('top', 0, 22), 'accent', 1.8),
    text('title', '{title}', 'Playfair Display SC', 33, at('top', 0, 30),
      { fontWeight: 700, letterSpacing: pt(0.4), lineHeight: 1 }),
    text('subtitle', '{subtitle}', 'Fanwood Text', 14, at('top', 0, 44), { italic: true }),
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
      placement: at('top', 0, 60, { width: 14 }) },
    label('author', '{author}', 10, at('top', 0, 66), 'ink', 2.4),
    image('mark', at('top', 0, 126, { width: 11 })),
    label('publisher', '{attr.publisher}', 7.5, at('top', 0, 140), 'muted', 1.5),
  ]) },
  // vi: the section's margins set the small print low, in a 70 mm measure against the outer
  // margin. With no element the design would print the title (gotcha: invisible-heading).
  { id: 'imprint', ...blind, margins: { top: mm(IMPRINT_TOP), left: mm(TRIM_W - OUTER - 70) },
    advancedDesign: design([{ kind: 'box', id: 'none', style: { backgroundColor: col('paper') },
      placement: { ...at('top-left', 0, 0), size: { width: pt(0.1), height: pt(0.1) } } }]) },
  // vii: dedication and epigraph, under a heading whose only element is an ornament, which
  // also keeps its title from printing (gotcha: invisible-heading).
  { id: 'quiet', ...blind,
    advancedDesign: design([image('crystal', at('top', 0, 24, { width: 6 }))], 34) },
];
// :::paragraphs blocks set the small print, the dedication, the epigraph and Walton's close;
// the dedication and the verse take one paragraph per line, so no line of them can reflow.
const VERSE_IN = 22; // mm: the indent that centres the verse as a block
const SOURCE_IN = 55; // mm: 'Paradise Lost' ends under the end of the longest line of verse
const prelimStyles = [
  // In ink, not muted: a paragraph style has no italic colour, so its italic title would take
  // bodyText.italicColor and print darker than its words (gotcha: style-italic-colour).
  { id: 'small-print', fontSize: pt(7.6), lineHeight: pt(10.4), textAlign: 'left',
    firstLineIndent: pt(0), spaceBetween: pt(5.2) },
  // Small capitals, with the italic lines of the 1818 page, leaded as one inscription.
  { id: 'dedication', fontFamily: 'Playfair Display SC', fontSize: pt(10), lineHeight: pt(15),
    textAlign: 'center', firstLineIndent: pt(0) },
  { id: 'verse', fontSize: pt(10), textAlign: 'left', firstLineIndent: mm(VERSE_IN),
    marginTop: pt(LEAD * 4) }, // on the body's 14 pt leading, which a style inherits
  { id: 'source', fontFamily: 'Cinzel', fontSize: pt(7.5), textAlign: 'left',
    firstLineIndent: mm(SOURCE_IN), marginTop: pt(4), color: col('muted') },
  { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, // the letter's close
];
```

Un título abre página y sale como marcador en el PDF; el diseño de su estilo coloca los textos y los dibujos de la hoja. Con `numbered: false` y `toc: false`, las hojas no cuentan como capítulo ni salen en el índice. Como la cabecera y el pie están vacíos, esas páginas cuentan pero no se folian: la portada no lleva número y sigue siendo la página v, y por eso el prólogo puede ser la ix. Solo la anteportada conserva el salto a impar, y eso deja en blanco el dorso de la cubierta. Los créditos, la dedicatoria, el epígrafe y la despedida de Walton son bloques `:::paragraphs` con los estilos del final del fragmento.

### 2 · El índice toma los números de la composición

```js
// script.js, líneas 158–167
// Entries take the body's face and ink, and start on the Preface's first baseline, facing it.
const contents = {
  levels: [{ level: 1, fontSize: pt(12), lineHeight: pt(PREFACE.lead) }],
  // The folio's face: "ix" in small capitals, then a plain 1. The leader takes it too.
  pageNumber: { fontFamily: 'Playfair Display SC', fontSize: pt(10), color: col('accent'),
    width: mm(8) },
  leader: { char: '. ', gap: mm(2.5) },
  // A second line from the heading's to="…" attribute: whom each letter is written to.
  subtitle: { enabled: true, attr: 'to', fontSize: pt(9.5), color: col('muted') },
};
```

`:::toc` recoge cada título que su estilo deja pasar, con la etiqueta de la página donde cae ([índice de contenidos](/es/docs/configuration#índice-de-contenidos)). El índice y el prólogo comparten el estilo `front`, que fija `toc: false`, y el prólogo lo anula con `{toc="true"}`. Ni el prólogo ni la Carta I van numerados, así que sus entradas no llevan número de capítulo. La segunda línea de la entrada de la carta es su atributo `to="…"`. Los números de página van en la letra de los folios. Las entradas arrancan a la altura de la primera línea del prólogo, de modo que las dos páginas del pliego casan.

### 3 · La novela abre en página impar, bajo la aurora boreal

```js
// script.js, líneas 171–186
const BAND = 104; // mm from the trim to the foot of the drawing, which fades into the paper
// minHeight ends AIR mm below it and the first line takes the next grid line. to: 'bleed' is the
// trim until page.cutLines adds a bleed; 3 mm drop the foot 1.8 mm, still above that line.
const AIR = 2;
const opener = design([
  image('band', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
  label('to', '{attr.to}', 7.5, onPage(19), 'ice', 2),
  text('title', '{titleText}', 'Playfair Display SC', 46, onPage(25),
    { color: col('bone'), lineHeight: 1 }),
  text('dateline', '{attr.dateline}', 'Fanwood Text', 10.5, onPage(44),
    { italic: true, color: col('bone') }),
  // minHeight counts from the text block, TOP below the trim. The texts reserve their height,
  // the drawing nothing (gotcha: opener-image-no-reserve), so minHeight sets the first line.
], BAND - TOP + AIR);
// Walton's letters come before Chapter I: they take the novel's opener but no number.
const letter = { id: 'letter', numbered: false };
```

El dibujo sale a sangre por la cabeza de la página y se funde con el papel al pie, donde empieza la carta. En Postext 1.4.1 una imagen nunca cuenta para el espacio que reserva una apertura (sus textos, filetes y cajas sí, aunque estén anclados a la página), así que solo `minHeight` baja la primera línea por debajo del dibujo ([span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)). El antetítulo y la fecha salen de los atributos `to` y `dateline` del título. La Carta I añade `{style="letter"}`, un estilo que se limita a fijar `numbered: false`, porque las cartas de Walton van antes del capítulo I y no se numeran.

### 4 · Las cabeceras vuelven con la novela

```js
// script.js, líneas 190–202
const HEAD_Y = 12.5; // mm from the trim to the heads' line
const HEAD_GAP = 8; // mm from the outer margin, where the folio hangs, to the words beside it
const head = (id, content, parity, edge, x, extra = {}) => ({ ...label(id, content, 7.5,
  at(edge, x, HEAD_Y, { to: 'page' }), 'muted', 1.4), align: 'left', parity, pages: 'body',
  ...extra }); // pages: 'body' keeps them off the opener
const numeral = { fontFamily: 'Playfair Display SC', fontSize: pt(8.5), fontWeight: 400,
  letterSpacing: pt(0.6), color: col('ink') }; // folios in the folio face, as on the openers
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, numeral),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + HEAD_GAP),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + HEAD_GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, numeral),
] };
```

Una sección conserva la cabecera y el pie del documento salvo que su estilo los sustituya. Los preliminares sustituyen los dos y el estilo de las cartas no, así que desde la página 1 la novela lleva estas cabeceras en sus páginas corrientes y, en la apertura, el folio al pie que define la respuesta corta. `{pageNumber}` imprime la etiqueta de la página en la que está, y por eso el mismo diseño de folio, el de `folio()`, pone x a un lado del lomo y 1 al otro.

### 5 · El PDF lleva la misma numeración

```js
// script.js, líneas 622–629
await loadFonts(FONTS, markdown);
// :::toc lists each heading with the label of the page it lands on; buildDocument lays the
// document out again until those labels stop moving (ix for the Preface, 1 for Letter I).
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Frankenstein · the opening leaves' });
// The PDF gets /PageLabels from the same labels: a viewer numbers its pages i–x, then 1, 2.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
```

`buildDocument` repite la composición hasta que las etiquetas del índice dejan de cambiar, así que basta con una llamada. `renderToPdf` lleva esas etiquetas al PDF: en un visor que lea las etiquetas de página, la numeración va de la i a la x y luego desde el 1, e «ir a la página 1» abre la Carta I. Cada título da un marcador con su propio texto, y por eso las hojas llevan nombres sencillos (en inglés, como la muestra): Cover, Half title, Frontispiece, Title page, Imprint, Dedication.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/front-matter-roman-to-arabic

### script.js

```js
// ═══ Postext Cookbook · Nº 006 · Front matter: roman folios, then page 1 ═══════════
// https://postext.dev/en/cookbook/front-matter-roman-to-arabic
// Code: MIT · Text: M. & P. B. Shelley, 1818 (PD) · Drawings: generated in code (CC BY 4.0)
// Fonts: Fanwood Text, Playfair Display SC, Cinzel (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'front-matter-roman-to-arabic';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#1c1d21', // text: a cold near-black
  accent: '#3d5a70', // steel blue, the one accent on paper: contents folios, the rule, the marks
  muted: '#6a665f', // running heads, sources, the second lines of the contents
  paper: '#fbf8f2', // the page
  night: '#131a24', ice: '#a9c6d6', bone: '#efe9dc', // the drawings' inks, and the type on them
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
const TRIM_W = 129, TRIM_H = 198; // mm: a pocket classic
const TOP = 21, BOTTOM = 23, INNER = 17, OUTER = 15; // margins, mm; heads align to OUTER
const MEASURE = TRIM_W - INNER - OUTER; // 97 mm: the text block, and the frontispiece's width
const LEAD = 14; // body leading in pt: 31 lines fill the text block
const PREFACE = { size: 9.8, lead: 13.3 }; // pt: the preface, a size under the novel's 10.5/14

// Design-slot shorthands. at(): a placement from an anchor's edge; edge 'top' centres it.
const wide = (width) => (width === 'fill' ? 'fill' : mm(width));
const at = (edge, x, y, { width, to = 'container' } = {}) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width !== undefined && { size: { width: wide(width) } }) });
const onPage = (y) => at('top', 0, y, { to: 'page' }); // centred on the page, y mm from the trim
const on = (id) => ({ color: col(id) }); // a design text's ink
// overflow 'wrap', or a text too wide ends in '…' (gotcha: overflow-ellipsis-default). A number
// lineHeight multiplies the size; never pt() (gotcha: design-lineheight-multiple).
const text = (id, content, font, size, placement, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap',
  placement, ...extra });
const label = (id, content, size, placement, ink, tracking) => text(id, content, 'Cinzel', size,
  placement, { fontWeight: 600, letterSpacing: pt(tracking), textTransform: 'uppercase',
    ...on(ink) }); // the label voice: Cinzel capitals, tracked
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
// A heading's design. minHeight (mm) where the text must start lower than the elements reach.
const design = (elements, minHeight) => ({ enabled: true, slot: { elements },
  ...(minHeight !== undefined && { minHeight: mm(minHeight) }) });
// The prelims' openers: a centred title in small capitals on white space, and the preface's
// text a size smaller than the novel's, on its own leading.
const FRONT_DROP = 46; // mm from the text block's top to the first line of text
const frontLook = {
  advancedDesign: design([text('title', '{titleText}', 'Playfair Display SC', 19,
    at('top', 0, 14), { letterSpacing: pt(1.6) })], FRONT_DROP),
  bodyStyle: { fontSize: pt(PREFACE.size), lineHeight: pt(PREFACE.lead) },
};

// #region answer: roman folios for the front matter and arabic from Letter I, in one design
// The page counter starts in lower-case roman: the cover is page i. After the Preface, two
// directives in the Markdown count again from 1 on the next recto:
//   :::pagebreak{parity="odd"}
//   :::numbering{format="decimal" startAt=1}
// :::numbering takes effect on the next page that starts (gotcha: numbering-next-page). Level 1
// breaks to 'odd', so here the pagebreak changes nothing; it finds the recto when a level breaks
// with parity 'any' or not at all. Never with 'always-odd': the blank pages would stack up.
const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
// One folio design, centred under the text block. {pageNumber} prints the page's own label,
// so the same design sets "x" in the front matter and "1" on the first page of the novel.
const folio = (pages) => text(`folio-${pages}`, '{pageNumber}', 'Playfair Display SC', 8.5,
  at('top', 0, 9), { letterSpacing: pt(0.8), pages }); // lower case sets as small capitals
// The front matter is a heading style, unnumbered and left out of the contents (a heading
// can opt back in with {toc="true"}). It prints no running heads and puts a folio on every
// page of the section. Parity 'any', because the contents fall on a verso.
const front = {
  id: 'front', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  header: { elements: [] }, footer: { elements: [folio('all')] }, ...frontLook,
};
// The novel keeps the document's furniture: heads on its body pages, the folio on openers.
const footer = { elements: [folio('opener')] };
// Hook-up in config(): page: { pageNumbering }, headingStyles: [front] and footer at the top
// level. frontLook, set above, is the look of the prelims' openers.
// #endregion

// #region leaves: the cover and the preliminary leaves, headings that print a design only
// Each leaf is a heading, so it opens its page, gets a PDF bookmark and takes a design. The
// leaves are unnumbered, left out of the contents and print no heads or folio (blind folios).
// Parity 'any', or each would inherit level 1's odd break (gotcha: style-inherits-break).
const blind = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  header: { elements: [] }, footer: { elements: [] } };
const PLATE_H = 126; // mm: the frontispiece, as wide as the text block; its caption hangs below
// mm from the trim: the imprint's eleven lines end on the text block's last baseline.
const IMPRINT_TOP = 116.5; // re-tune it whenever the imprint changes
const leaves = [
  // i: bled to the trim. Type below the text block's foot would drop the heading's reserve.
  { id: 'cover', ...blind, advancedDesign: design([
    image('cover', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
    label('author', '{author}', 9, onPage(22), 'bone', 2.6),
    text('title', '{title}', 'Fanwood Text', 56, onPage(31), { color: col('bone'), lineHeight: 1 }),
    text('subtitle', '{subtitle}', 'Fanwood Text', 13, onPage(53), { ...on('ice'), italic: true }),
    label('series', '{attr.publisher}', 7, onPage(168), 'ice', 2),
  ]) },
  // iii: the half title waits for a recto, so the back of the cover, ii, is left blank.
  { id: 'half', ...blind, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: design([
    text('title', '{title}', 'Playfair Display SC', 17, at('top', 0, 34), { letterSpacing: pt(3) }),
    image('crystal', at('top', 0, 45, { width: 6 })),
  ]) },
  { id: 'plate', ...blind, advancedDesign: design([ // iv: faces the title page
    image('plate', at('top-left', 0, 0, { width: 'fill' })),
    text('caption', '{attr.caption}', 'Fanwood Text', 8.8,
      at('top', 0, PLATE_H + 5, { width: 80 }), { italic: true, lineHeight: 1.3 }),
    label('source', '{attr.source}', 7.5, at('top', 0, PLATE_H + 16), 'muted', 1.3),
  ]) },
  { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
    // {publishDate} prints because the year is quoted (gotcha: quote-frontmatter).
    label('edition', 'The text of {publishDate}', 7.5, at('top', 0, 22), 'accent', 1.8),
    text('title', '{title}', 'Playfair Display SC', 33, at('top', 0, 30),
      { fontWeight: 700, letterSpacing: pt(0.4), lineHeight: 1 }),
    text('subtitle', '{subtitle}', 'Fanwood Text', 14, at('top', 0, 44), { italic: true }),
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
      placement: at('top', 0, 60, { width: 14 }) },
    label('author', '{author}', 10, at('top', 0, 66), 'ink', 2.4),
    image('mark', at('top', 0, 126, { width: 11 })),
    label('publisher', '{attr.publisher}', 7.5, at('top', 0, 140), 'muted', 1.5),
  ]) },
  // vi: the section's margins set the small print low, in a 70 mm measure against the outer
  // margin. With no element the design would print the title (gotcha: invisible-heading).
  { id: 'imprint', ...blind, margins: { top: mm(IMPRINT_TOP), left: mm(TRIM_W - OUTER - 70) },
    advancedDesign: design([{ kind: 'box', id: 'none', style: { backgroundColor: col('paper') },
      placement: { ...at('top-left', 0, 0), size: { width: pt(0.1), height: pt(0.1) } } }]) },
  // vii: dedication and epigraph, under a heading whose only element is an ornament, which
  // also keeps its title from printing (gotcha: invisible-heading).
  { id: 'quiet', ...blind,
    advancedDesign: design([image('crystal', at('top', 0, 24, { width: 6 }))], 34) },
];
// :::paragraphs blocks set the small print, the dedication, the epigraph and Walton's close;
// the dedication and the verse take one paragraph per line, so no line of them can reflow.
const VERSE_IN = 22; // mm: the indent that centres the verse as a block
const SOURCE_IN = 55; // mm: 'Paradise Lost' ends under the end of the longest line of verse
const prelimStyles = [
  // In ink, not muted: a paragraph style has no italic colour, so its italic title would take
  // bodyText.italicColor and print darker than its words (gotcha: style-italic-colour).
  { id: 'small-print', fontSize: pt(7.6), lineHeight: pt(10.4), textAlign: 'left',
    firstLineIndent: pt(0), spaceBetween: pt(5.2) },
  // Small capitals, with the italic lines of the 1818 page, leaded as one inscription.
  { id: 'dedication', fontFamily: 'Playfair Display SC', fontSize: pt(10), lineHeight: pt(15),
    textAlign: 'center', firstLineIndent: pt(0) },
  { id: 'verse', fontSize: pt(10), textAlign: 'left', firstLineIndent: mm(VERSE_IN),
    marginTop: pt(LEAD * 4) }, // on the body's 14 pt leading, which a style inherits
  { id: 'source', fontFamily: 'Cinzel', fontSize: pt(7.5), textAlign: 'left',
    firstLineIndent: mm(SOURCE_IN), marginTop: pt(4), color: col('muted') },
  { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, // the letter's close
];
// #endregion

// #region contents: what :::toc prints: titles, dotted leaders, the labels each page prints
// Entries take the body's face and ink, and start on the Preface's first baseline, facing it.
const contents = {
  levels: [{ level: 1, fontSize: pt(12), lineHeight: pt(PREFACE.lead) }],
  // The folio's face: "ix" in small capitals, then a plain 1. The leader takes it too.
  pageNumber: { fontFamily: 'Playfair Display SC', fontSize: pt(10), color: col('accent'),
    width: mm(8) },
  leader: { char: '. ', gap: mm(2.5) },
  // A second line from the heading's to="…" attribute: whom each letter is written to.
  subtitle: { enabled: true, attr: 'to', fontSize: pt(9.5), color: col('muted') },
};
// #endregion

// #region letter: the novel's opener: the northern lights, bled off the head of the recto
const BAND = 104; // mm from the trim to the foot of the drawing, which fades into the paper
// minHeight ends AIR mm below it and the first line takes the next grid line. to: 'bleed' is the
// trim until page.cutLines adds a bleed; 3 mm drop the foot 1.8 mm, still above that line.
const AIR = 2;
const opener = design([
  image('band', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
  label('to', '{attr.to}', 7.5, onPage(19), 'ice', 2),
  text('title', '{titleText}', 'Playfair Display SC', 46, onPage(25),
    { color: col('bone'), lineHeight: 1 }),
  text('dateline', '{attr.dateline}', 'Fanwood Text', 10.5, onPage(44),
    { italic: true, color: col('bone') }),
  // minHeight counts from the text block, TOP below the trim. The texts reserve their height,
  // the drawing nothing (gotcha: opener-image-no-reserve), so minHeight sets the first line.
], BAND - TOP + AIR);
// Walton's letters come before Chapter I: they take the novel's opener but no number.
const letter = { id: 'letter', numbered: false };
// #endregion

// #region heads: running heads on the novel's body pages; the book on versos, letter on rectos
const HEAD_Y = 12.5; // mm from the trim to the heads' line
const HEAD_GAP = 8; // mm from the outer margin, where the folio hangs, to the words beside it
const head = (id, content, parity, edge, x, extra = {}) => ({ ...label(id, content, 7.5,
  at(edge, x, HEAD_Y, { to: 'page' }), 'muted', 1.4), align: 'left', parity, pages: 'body',
  ...extra }); // pages: 'body' keeps them off the opener
const numeral = { fontFamily: 'Playfair Display SC', fontSize: pt(8.5), fontWeight: 400,
  letterSpacing: pt(0.6), color: col('ink') }; // folios in the folio face, as on the openers
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, numeral),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + HEAD_GAP),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + HEAD_GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, numeral),
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'en-us', // hyphenation patterns, by exact code (gotcha: hyphenation-locales)
  colorPalette, layout: { layoutType: 'single' },
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    backgroundColor: col('paper'), pageNumbering,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  bodyText: { // justified, hyphenated, optimal line breaking, widow control: the defaults
    fontFamily: 'Fanwood Text', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // An even grey: spaces 0.75–1.75 of their width; a last line under 36 spaces' width (about
    // 18 letters: a lone 'occurrence.') is a runt, and costs three times the default.
    minWordSpacing: 0.75, maxWordSpacing: 1.75, runtMinCharacters: 36, runtPenalty: 3000,
  },
  headings: {
    fontFamily: 'Playfair Display SC', fontWeight: 400, color: col('ink'),
    // Break restated (gotcha: headings-drop-h1-break). span 'page' paints the opener unclipped,
    // so the drawing reaches the trim; an in-column design is clipped to the text block.
    levels: [{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }],
  },
  headingStyles: [...leaves, front, letter],
  toc: contents,
  paragraphStyles: prelimStyles,
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Frankenstein"
subtitle: "or, The Modern Prometheus"
author: "Mary Shelley"
publishDate: "1818"
---

# Cover {style="cover" publisher="Postext Classics"}

# Half title {style="half"}

# Frontispiece {style="plate" caption="We perceived a low carriage, fixed on a sledge and drawn by dogs, pass on towards the north, at the distance of half a mile." source="Letter IV"}

# Title page {style="title" publisher="Postext Classics · MMXXVI"}

# Imprint {style="imprint"}

:::paragraphs{style="small-print"}
*Frankenstein; or, The Modern Prometheus* was first published anonymously, in three volumes, by Lackington, Hughes, Harding, Mavor & Jones of Finsbury Square, London, on 1 January 1818.

This edition follows that first text, transcribed by Project Gutenberg from a photographic reprint (eBook 41445), and keeps its spelling: phænomena, their’s, St. Petersburgh.

Set in Fanwood Text, Playfair Display SC and Cinzel, all under the SIL Open Font License. Cover, frontispiece and publisher’s mark drawn in code; pages laid out in the browser by Postext.

Text: public domain. Design and editorial matter: CC BY 4.0.

Postext Cookbook · No. 6
:::

# Dedication {style="quiet"}

:::paragraphs{style="dedication"}
*To*

William Godwin,

*Author of Political Justice,*

*Caleb Williams, &c.*

These volumes

*Are respectfully inscribed*

By

The Author.
:::

:::paragraphs{style="verse"}
*Did I request thee, Maker, from my clay*

*To mould me man? Did I solicit thee*

*From darkness to promote me?——*
:::

:::paragraphs{style="source"}
Paradise Lost
:::

# Contents {style="front"}

:::toc

# Preface {style="front" toc="true"}

The event on which this fiction is founded has been supposed, by Dr. Darwin, and some of the physiological writers of Germany, as not of impossible occurrence. I shall not be supposed as according the remotest degree of serious faith to such an imagination; yet, in assuming it as the basis of a work of fancy, I have not considered myself as merely weaving a series of supernatural terrors. The event on which the interest of the story depends is exempt from the disadvantages of a mere tale of spectres or enchantment. It was recommended by the novelty of the situations which it developes; and, however impossible as a physical fact, affords a point of view to the imagination for the delineating of human passions more comprehensive and commanding than any which the ordinary relations of existing events can yield.

I have thus endeavoured to preserve the truth of the elementary principles of human nature, while I have not scrupled to innovate upon their combinations. The *Iliad*, the tragic poetry of Greece,—Shakespeare, in the *Tempest* and *Midsummer Night’s Dream*,—and most especially Milton, in *Paradise Lost*, conform to this rule; and the most humble novelist, who seeks to confer or receive amusement from his labours, may, without presumption, apply to prose fiction a licence, or rather a rule, from the adoption of which so many exquisite combinations of human feeling have resulted in the highest specimens of poetry.

The circumstance on which my story rests was suggested in casual conversation. It was commenced, partly as a source of amusement, and partly as an expedient for exercising any untried resources of mind. Other motives were mingled with these, as the work proceeded. I am by no means indifferent to the manner in which whatever moral tendencies exist in the sentiments or characters it contains shall affect the reader; yet my chief concern in this respect has been limited to the avoiding of the enervating effects of the novels of the present day, and to the exhibitions of the amiableness of domestic affection, and the excellence of universal virtue. The opinions which naturally spring from the character and situation of the hero are by no means to be conceived as existing always in my own conviction; nor is any inference justly to be drawn from the following pages as prejudicing any philosophical doctrine of whatever kind.

It is a subject also of additional interest to the author, that this story was begun in the majestic region where the scene is principally laid, and in society which cannot cease to be regretted. I passed the summer of 1816 in the environs of Geneva. The season was cold and rainy, and in the evenings we crowded around a blazing wood fire, and occasionally amused ourselves with some German stories of ghosts, which happened to fall into our hands. These tales excited in us a playful desire of imitation. Two other friends (a tale from the pen of one of whom would be far more acceptable to the public than any thing I can ever hope to produce) and myself agreed to write each a story, founded on some supernatural occurrence.

The weather, however, suddenly became serene; and my two friends left me on a journey among the Alps, and lost, in the magnificent scenes which they present, all memory of their ghostly visions. The following tale is the only one which has been completed.

:::pagebreak{parity="odd"}

:::numbering{format="decimal" startAt=1}

# Letter I {style="letter" to="To Mrs. Saville, England" dateline="St. Petersburgh, Dec. 11th, 17—"}

You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. I arrived here yesterday; and my first task is to assure my dear sister of my welfare, and increasing confidence in the success of my undertaking.

I am already far north of London; and as I walk in the streets of Petersburgh, I feel a cold northern breeze play upon my cheeks, which braces my nerves, and fills me with delight. Do you understand this feeling? This breeze, which has travelled from the regions towards which I am advancing, gives me a foretaste of those icy climes. Inspirited by this wind of promise, my day dreams become more fervent and vivid. I try in vain to be persuaded that the pole is the seat of frost and desolation; it ever presents itself to my imagination as the region of beauty and delight. There, Margaret, the sun is for ever visible; its broad disk just skirting the horizon, and diffusing a perpetual splendour. There—for with your leave, my sister, I will put some trust in preceding navigators—there snow and frost are banished; and, sailing over a calm sea, we may be wafted to a land surpassing in wonders and in beauty every region hitherto discovered on the habitable globe. Its productions and features may be without example, as the phænomena of the heavenly bodies undoubtedly are in those undiscovered solitudes. What may not be expected in a country of eternal light? I may there discover the wondrous power which attracts the needle; and may regulate a thousand celestial observations, that require only this voyage to render their seeming eccentricities consistent for ever. I shall satiate my ardent curiosity with the sight of a part of the world never before visited, and may tread a land never before imprinted by the foot of man. These are my enticements, and they are sufficient to conquer all fear of danger or death, and to induce me to commence this laborious voyage with the joy a child feels when he embarks in a little boat, with his holiday mates, on an expedition of discovery up his native river. But, supposing all these conjectures to be false, you cannot contest the inestimable benefit which I shall confer on all mankind to the last generation, by discovering a passage near the pole to those countries, to reach which at present so many months are requisite; or by ascertaining the secret of the magnet, which, if at all possible, can only be effected by an undertaking such as mine.

These reflections have dispelled the agitation with which I began my letter, and I feel my heart glow with an enthusiasm which elevates me to heaven; for nothing contributes so much to tranquillize the mind as a steady purpose,—a point on which the soul may fix its intellectual eye. This expedition has been the favourite dream of my early years. I have read with ardour the accounts of the various voyages which have been made in the prospect of arriving at the North Pacific Ocean through the seas which surround the pole. You may remember, that a history of all the voyages made for purposes of discovery composed the whole of our good uncle Thomas’s library. My education was neglected, yet I was passionately fond of reading. These volumes were my study day and night, and my familiarity with them increased that regret which I had felt, as a child, on learning that my father’s dying injunction had forbidden my uncle to allow me to embark in a sea-faring life.

These visions faded when I perused, for the first time, those poets whose effusions entranced my soul, and lifted it to heaven. I also became a poet, and for one year lived in a Paradise of my own creation; I imagined that I also might obtain a niche in the temple where the names of Homer and Shakespeare are consecrated. You are well acquainted with my failure, and how heavily I bore the disappointment. But just at that time I inherited the fortune of my cousin, and my thoughts were turned into the channel of their earlier bent.

Six years have passed since I resolved on my present undertaking. I can, even now, remember the hour from which I dedicated myself to this great enterprise. I commenced by inuring my body to hardship. I accompanied the whale-fishers on several expeditions to the North Sea; I voluntarily endured cold, famine, thirst, and want of sleep; I often worked harder than the common sailors during the day, and devoted my nights to the study of mathematics, the theory of medicine, and those branches of physical science from which a naval adventurer might derive the greatest practical advantage. Twice I actually hired myself as an under-mate in a Greenland whaler, and acquitted myself to admiration. I must own I felt a little proud, when my captain offered me the second dignity in the vessel, and entreated me to remain with the greatest earnestness; so valuable did he consider my services.

And now, dear Margaret, do I not deserve to accomplish some great purpose. My life might have been passed in ease and luxury; but I preferred glory to every enticement that wealth placed in my path. Oh, that some encouraging voice would answer in the affirmative! My courage and my resolution is firm; but my hopes fluctuate, and my spirits are often depressed. I am about to proceed on a long and difficult voyage; the emergencies of which will demand all my fortitude: I am required not only to raise the spirits of others, but sometimes to sustain my own, when their’s are failing.

This is the most favourable period for travelling in Russia. They fly quickly over the snow in their sledges; the motion is pleasant, and, in my opinion, far more agreeable than that of an English stage-coach. The cold is not excessive, if you are wrapt in furs, a dress which I have already adopted; for there is a great difference between walking the deck and remaining seated motionless for hours, when no exercise prevents the blood from actually freezing in your veins. I have no ambition to lose my life on the post-road between St. Petersburgh and Archangel.

I shall depart for the latter town in a fortnight or three weeks; and my intention is to hire a ship there, which can easily be done by paying the insurance for the owner, and to engage as many sailors as I think necessary among those who are accustomed to the whale-fishing. I do not intend to sail until the month of June: and when shall I return? Ah, dear sister, how can I answer this question? If I succeed, many, many months, perhaps years, will pass before you and I may meet. If I fail, you will see me again soon, or never.

Farewell, my dear, excellent, Margaret. Heaven shower down blessings on you, and save me, that I may again and again testify my gratitude for all your love and kindness.

:::paragraphs{style="signature"}
*Your affectionate brother,*

R. Walton.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a storm over the Alps, the brig in the ice, an aurora, a mark and a crystal
let seed = 1818; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2),
  16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0'))
  .join('')}`;
const f = (n) => n.toFixed(2);
const poly = (pts, fill, a = 1) => `<path d="M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z"`
  + ` fill="${fill}" fill-opacity="${a}"/>`;
const disk = (x, y, r, fill, a = 1) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" `
  + `fill="${fill}" fill-opacity="${a}"/>`;
const rect = (x, y, w, h, fill, a = 1) => poly([[x, y], [x + w, y], [x + w, y + h], [x, y + h]],
  fill, a);
const floe = (cx, cy, rx, ry, fill) => poly(Array.from({ length: 9 }, (_, i) => { // broken ice
  const a = (i / 9) * Math.PI * 2 + rand() * 0.5;
  const r = 0.72 + rand() * 0.34;
  return [cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r];
}), fill);
const PX = 10; // each drawing is w × h mm in its viewBox and declares w·PX × h·PX pixels
const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
// [cx, cy, rx, ry]: an ellipse in mm; `clear` keeps the stars out of it.
const inside = (e, x, y) => !!e && ((x - e[0]) / e[2]) ** 2 + ((y - e[1]) / e[3]) ** 2 < 1;

function sky(w, horizon, glow = 1) { // night at the top, a steel glow at the horizon
  const out = [];
  for (let i = 0; i < 96; i++) {
    const [y0, y1] = [horizon * i / 96, horizon * (i + 1) / 96 + 0.2];
    out.push(rect(0, y0, w, y1 - y0, mix('night', 'accent', glow * (i / 95) ** 2.4)));
  }
  return out;
}
function stars(out, w, depth, n, clear) { // thinning towards the glow, none behind the type
  for (let i = 0; i < n; i++) {
    const y = rand() ** 1.7 * depth;
    const [x, r, a] = [rand() * w, 0.12 + rand() ** 3 * 0.38, 0.3 + rand() * 0.6];
    if (!inside(clear, x, y)) out.push(disk(x, y, r, palette.bone, a));
  }
}
function fadeOut(out, w, h, depth) { // the foot of a drawing dissolves into the page
  for (let y = h - depth; y < h; y += 0.8) out.push(rect(0, y, w, h - y, palette.paper, 0.15));
}

// "The sun is for ever visible; its broad disk just skirting the horizon" (Letter I).
function arctic(w, h, { horizon, sun }) {
  const out = sky(w, horizon);
  stars(out, w, horizon * 0.72, 110, null);
  const r = w * 0.14;
  out.push(disk(sun * w, horizon, r * 2.3, palette.bone, 0.05),
    disk(sun * w, horizon, r * 1.5, palette.bone, 0.08), disk(sun * w, horizon, r, palette.bone));
  const ridge = [[0, horizon + 0.4]]; // a far ridge of pressure ice along the horizon
  for (let x = 0; x <= w; x += 1 + rand() * 3) ridge.push([x, horizon - rand() ** 2 * 2.2]);
  out.push(poly([...ridge, [w, horizon + 0.4]], mix('accent', 'ice', 0.35)));
  out.push(rect(0, horizon, w, h - horizon, mix('night', 'accent', 0.55)));
  const rows = 22; // floes: slivers at the horizon, broad plates near the reader
  for (let k = 0; k < rows; k++) {
    const t = (k + 0.5) / rows;
    const y = horizon + 0.6 + (h - horizon) * t ** 1.9;
    const ry = (h - horizon) * 1.9 * t ** 0.9 / rows * (0.55 + rand() * 0.3);
    for (let x = -rand() * 8; x < w + 6;) {
      const rx = ry * (2.2 + rand() * 4.5);
      const glint = Math.max(0, 1 - Math.abs(x - sun * w) / (w * 0.2)) * 0.3;
      out.push(floe(x, y + (rand() - 0.5) * ry, rx, ry,
        mix('accent', 'paper', Math.min(1, 0.4 + t * 0.55 + glint + (rand() - 0.5) * 0.14))));
      x += rx * (1.6 + rand() * 0.9) + 0.3 + t * 1.6;
    }
  }
  const [sx, sy] = [w * 0.18, horizon + 1.2]; // half a mile off: dogs, the sledge, its driver
  for (let i = 0; i < 6; i++) out.push(disk(sx + i * 0.8, sy, 0.3, palette.night));
  out.push(poly([[sx - 2.6, sy - 0.3], [sx - 0.6, sy - 0.3], [sx - 0.5, sy + 0.4],
    [sx - 2.7, sy + 0.4]], palette.night), poly([[sx - 2.2, sy - 0.3], [sx - 2.05, sy - 2.1],
    [sx - 1.5, sy - 2.2], [sx - 1.35, sy - 0.3]], palette.night));
  out.push(brig(w * 0.5, h - 16, 0.95)); // beset in the ice, heeled over
  return svgOf(w, h, out.join(''));
}

function brig(x, y, s) { // a two-masted brig in silhouette, sails furled on the yards
  const ink = palette.night;
  const spar = (x1, y1, x2, y2, wd) => { // a straight spar or stay as a thin quadrilateral
    const [dx, dy] = [x2 - x1, y2 - y1];
    const [nx, ny] = [(-dy / Math.hypot(dx, dy)) * wd / 2, (dx / Math.hypot(dx, dy)) * wd / 2];
    return poly([[x1 + nx, y1 + ny], [x2 + nx, y2 + ny], [x2 - nx, y2 - ny],
      [x1 - nx, y1 - ny]], ink);
  };
  const parts = [poly([[-1, -2.5], [3, -1.2], [41, -1.6], [44, -3.2], [39.5, 5], [5, 5.6]], ink),
    spar(41, -2, 55, -8.5, 0.7), spar(55, -8.5, 28.3, -44, 0.25), spar(0, -2, 13.4, -41, 0.25),
    spar(14.2, -30, 27.6, -34, 0.25)]; // hull, bowsprit and the stays
  for (const [mx, tall] of [[13.4, 41], [27.8, 44]]) {
    parts.push(spar(mx, 0, mx, -tall, 0.9), poly([[mx, -tall - 3.6], [mx + 4, -tall - 2.6],
      [mx, -tall - 1.6]], ink)); // mast and pennant
    [17, 14, 11, 7].forEach((yard, i) => parts.push(spar(mx - yard / 2, -9 - i * 8.6,
      mx + yard / 2, -9 - i * 8.6, 1.3 - i * 0.2)));
  }
  const floes = [[-8, 5], [2, 3.5], [16, 6.5], [29, 4], [42, 6.5]].map(([fx, fh]) =>
    poly([[fx, 6.5], [fx + 4, 6.5 - fh], [fx + 9, 5.5], [fx + 13, 7.5]], palette.paper));
  return `<g transform="translate(${f(x)} ${f(y)}) scale(${s}) rotate(-4)">${parts.join('')}`
    + `${floes.join('')}</g>`;
}

// "I saw the lightnings playing on the summit of Mont Blânc in the most beautiful figures"
// (vol. I, chapter VI): the cover, a storm over the Alps and the lake of Geneva.
function storm(w, h) {
  const shore = 150; // mm: the lake's far shore
  const out = sky(w, shore, 0.75);
  stars(out, w, 58, 46, [w / 2, 38, 58, 28]);
  const [bx, peak] = [w * 0.57, 121]; // the bolt strikes the dome of Mont Blanc
  const glow = (x, y, r, a, n = 10) => { // a soft light: many faint discs, no gradient
    for (let i = 0; i < n; i++) out.push(disk(x, y, r * (1 - i / n), palette.bone, a));
  };
  const clouds = (top, depth, tone) => { // a bank with a scalloped crown and a ragged belly
    const edge = [];
    for (let x = -6; x < w + 6;) {
      const r = 3 + rand() * 6;
      for (let k = 0; k <= 6; k++) {
        const t = (k / 6) * Math.PI;
        edge.push([x + r * (1 - Math.cos(t)), top - r * 0.55 * Math.sin(t) + rand() * 0.4]);
      }
      x += 2 * r * (0.7 + rand() * 0.2);
    }
    const belly = [];
    for (let x = w + 6; x > -6; x -= 3 + rand() * 4) belly.push([x, top + depth + rand() * 3]);
    out.push(poly([...edge, ...belly], tone));
  };
  clouds(64, 12, mix('night', 'accent', 0.42));
  clouds(72, 12, mix('night', 'accent', 0.28));
  glow(bx + 2, 100, 46, 0.011, 24); // the flash, lighting the air and the clouds' bellies
  clouds(80, 13, mix('night', 'accent', 0.16));
  glow(bx + 4, 94, 18, 0.015, 16);
  const bolt = [[bx + 11, 88]]; // from the cloud's belly down to the summit, in zigzags
  for (let i = 1; i < 11; i++) {
    const t = i / 11;
    const kink = (i % 2 ? 1 : -1) * (1.5 + rand() * 2.5);
    bolt.push([bx + 11 * (1 - t) + kink, 88 + (peak - 88) * t]);
  }
  bolt.push([bx, peak]);
  const zig = (pts, wd, fill, a) => poly([...pts.map(([x, y], i) => [x - wd * (1 - i / pts.length),
    y]), ...pts.slice().reverse().map(([x, y], i) => [x + wd * (i / pts.length) + 0.05, y])],
  fill, a);
  const fork = [bolt[4], ...[1, 2, 3, 4, 5].map((k) => [bolt[4][0] - k * 2.6
    + (k % 2 ? 1 : -1) * rand() * 1.4, bolt[4][1] + k * 2.3])];
  out.push(zig(bolt, 2.6, palette.bone, 0.18), zig(fork, 1.2, palette.bone, 0.14),
    zig(bolt, 0.8, palette.bone, 1), zig(fork, 0.35, palette.bone, 0.9));
  const dome = (x) => peak + (Math.abs(x - bx) / (x < bx ? 30 : 24)) ** 1.6 * 17; // the summit
  const far = [];
  for (let x = -2; x <= w + 2; x += 1.5 + rand() * 2.5) { // needles on the shoulders
    far.push([x, Math.min(140 - rand() ** 1.5 * 9, dome(x) + rand() * 1.2)]);
  }
  out.push(poly([[-2, shore + 2], ...far, [w + 2, shore + 2]], mix('night', 'accent', 0.32)));
  const cap = far.filter(([x]) => Math.abs(x - bx) < 24); // the snowfield, tapering to nothing
  const foot = cap.slice().reverse().map(([x, y], i) => [x, y + 11 * (1 - ((x - bx) / 24) ** 2)
    * (i % 2 ? 1 : 0.7) + rand()]);
  const lit = [...cap.filter(([x]) => x <= bx), [bx + 3, peak + 9],
    ...foot.filter(([x]) => x < bx)];
  out.push(poly([...cap, ...foot], mix('accent', 'ice', 0.5)), // in shade, then the lit face
    poly(lit, mix('ice', 'bone', 0.45)));
  const near = [[-2, shore + 2]];
  for (let x = -2; x <= w + 2; x += 3 + rand() * 4) near.push([x, 146 - 2 * rand()]);
  out.push(poly([...near, [w + 2, shore + 2]], mix('night', 'accent', 0.16)));
  out.push(rect(0, shore, w, h - shore, mix('night', 'accent', 0.34))); // the lake
  for (let k = 0; k < 30; k++) { // the flash on the water, and the ripples
    const y = shore + 1 + (h - shore - 3) * (k / 30) ** 1.4;
    const spread = 2 + k * 0.9;
    const row = [rect(bx - spread / 2 + (rand() - 0.5) * 3, y, spread * (0.5 + rand() * 0.5), 0.35,
      palette.bone, 0.5 - k * 0.014)];
    for (let i = 0; i < 3; i++) {
      row.push(rect(rand() * w, y, 2 + rand() * 8, 0.25, palette.ice, 0.1));
    }
    // Calm water under the series line (168–171 mm): the row is drawn, and its random numbers
    // spent, everywhere else, so the other drawings keep their seeded shapes.
    if (y < 164 || y > 173) out.push(...row);
  }
  return svgOf(w, h, out.join(''));
}

// "I feel a cold northern breeze play upon my cheeks" (Letter I): the northern lights over the
// Neva, St Petersburgh's spire on the far shore, and no ice yet.
function aurora(w, h, { horizon, clear, fade }) {
  const out = sky(w, horizon, 0.8);
  stars(out, w, horizon * 0.8, 120, clear);
  const floor = (x) => (Math.abs(x - clear[0]) < clear[2] // the light stops under the type
    ? clear[1] + clear[3] * Math.sqrt(1 - ((x - clear[0]) / clear[2]) ** 2) : 0);
  [[60, 4, 19, 0.075], [68, 2.5, 12, 0.1]].forEach(([base, amp, len, a], c) => {
    const phase = 1 + c * 2.4;
    const hem = (x) => base + amp * Math.sin(x / w * 6 + phase) + 1.2 * Math.sin(x / w * 17 + c);
    const top = (x) => Math.max(hem(x) - len * (0.55 + 0.45 * Math.sin(x / w * 9 + phase * 2)),
      floor(x));
    const xs = Array.from({ length: 131 }, (_, i) => -1 + (w + 2) * i / 130);
    for (let s = 0; s < 12; s++) { // a curtain: twelve veils, fainter towards its top
      const at = (x, k) => hem(x) - (hem(x) - top(x)) * Math.min(1, k);
      out.push(poly([...xs.map((x) => [x, at(x, (s + 1) / 12)]),
        ...xs.slice().reverse().map((x) => [x, at(x, 0)])], palette.ice, a * (1 - s / 12)));
    }
    out.push(poly([...xs.map((x) => [x, hem(x) - 0.6]), ...xs.slice().reverse()
      .map((x) => [x, hem(x) + 0.3])], palette.bone, 0.2)); // the bright lower hem
    for (let x = rand() * 2; x < w; x += 0.6 + rand() * 2.2) { // fine rays
      const y1 = hem(x);
      const y0 = y1 - (y1 - top(x)) * (0.3 + rand() * 0.7);
      if (y0 < y1) out.push(rect(x, y0, 0.18, y1 - y0, palette.bone, 0.06 + rand() * 0.1));
    }
  });
  out.push(rect(0, horizon, w, h - horizon, mix('night', 'accent', 0.4))); // the Neva
  const city = [[0, horizon + 0.3]]; // the far shore, low, with a spire and two domes
  for (let x = 0; x < w * 0.42; x += 1 + rand() * 2.5) {
    const y = horizon - 0.6 - rand() * 1.8;
    city.push([x, y], [x + 1.2, y]);
  }
  city.push([w * 0.42 + 4, horizon + 0.3]);
  const spire = w * 0.23;
  const dome = (x, r) => rect(x - r, horizon - 3.4 - r, 2 * r, 3.4 + r, palette.night)
    + disk(x, horizon - 3.4 - r, r, palette.night)
    + rect(x - 0.1, horizon - 5.6 - 2 * r, 0.2, 2.4, palette.night);
  out.push(poly(city, palette.night), poly([[spire - 1, horizon - 2], [spire, horizon - 15],
    [spire + 1, horizon - 2]], palette.night), dome(w * 0.31, 1.5), dome(w * 0.12, 1.1));
  for (let i = 0; i < 14; i++) {
    out.push(disk(rand() * w * 0.4, horizon - 0.8 - rand(), 0.16, palette.bone, 0.8));
  }
  for (let k = 0; k < 22; k++) { // the lights' long reflections on the water
    const y = horizon + 1 + (h - horizon) * (k / 22) ** 1.4;
    for (let x = rand() * 6; x < w; x += 4 + rand() * 10) {
      out.push(rect(x, y, 1.5 + rand() * 6, 0.3, palette.ice, 0.08 + rand() * 0.16));
    }
  }
  fadeOut(out, w, h, fade);
  return svgOf(w, h, out.join(''));
}

// The publisher's mark: a polar star in a double ring. The ornament: an ice crystal.
const EMBLEM = 40; // mm: both are drawn on a 40 × 40 viewBox
function mark() {
  const star = [];
  for (let i = 0; i < 16; i++) {
    const a = (i * Math.PI) / 8;
    const rr = i % 4 === 0 ? 13 : i % 2 === 0 ? 7 : 2.6;
    star.push([20 + rr * Math.sin(a), 20 - rr * Math.cos(a)]);
  }
  const rings = [[19, 'accent'], [18, 'paper'], [16.2, 'accent'], [15.6, 'paper']];
  return svgOf(EMBLEM, EMBLEM, rings.map(([rr, c]) => disk(20, 20, rr, palette[c])).join('')
    + poly(star, palette.ink));
}
function crystal() {
  const arm = poly([[19.5, 20], [19.5, 3], [20, 1.5], [20.5, 3], [20.5, 20]], palette.accent)
    + poly([[20, 9], [15, 5], [15.5, 4.4], [20, 7.8], [24.5, 4.4], [25, 5]], palette.accent);
  return svgOf(EMBLEM, EMBLEM, [0, 60, 120, 180, 240, 300].map((a) =>
    `<g transform="rotate(${a} 20 20)">${arm}</g>`).join('') + disk(20, 20, 2.4, palette.accent));
}

const art = {
  cover: storm(TRIM_W, TRIM_H),
  plate: arctic(MEASURE, PLATE_H, { horizon: 58, sun: 0.66 }),
  // No stars or rays behind the kicker, title and dateline (19–48 mm down): an ellipse.
  band: aurora(TRIM_W, BAND, { horizon: 76, clear: [TRIM_W / 2, 33, 50, 16], fade: 19 }),
  mark: mark(), crystal: crystal(),
};
for (const [id, svg] of Object.entries(art)) await loadSvg(`${id}.svg`, svg);
// #endregion

// Resources only the designs use, nothing cites them; each declares svgOf's pixel size.
const svgResource = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  svgResource('cover', TRIM_W, TRIM_H, 'Lightning strikes Mont Blanc over the lake of Geneva.'),
  svgResource('plate', MEASURE, PLATE_H, 'A brig beset in pack ice, a sledge far off.'),
  svgResource('band', TRIM_W, BAND, 'Northern lights over the Neva and St Petersburgh.'),
  svgResource('mark', EMBLEM, EMBLEM, 'Publisher’s mark: a polar star in a double ring.'),
  svgResource('crystal', EMBLEM, EMBLEM, 'Ornament: an ice crystal.'),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before the first build (gotcha: fonts-first). The PDF asks for a bold Fanwood and an
// italic Cinzel too; the kit's provider snaps to shipped faces (gotcha: pdf-provider-all-styles).
const FONTS = { 'Fanwood Text': ['400', '400i'], Cinzel: ['400', '600'],
  'Playfair Display SC': ['400', '400i', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: lay out once (buildDocument settles the contents), show, offer a PDF
await loadFonts(FONTS, markdown);
// :::toc lists each heading with the label of the page it lands on; buildDocument lays the
// document out again until those labels stop moving (ix for the Preface, 1 for Letter I).
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Frankenstein · the opening leaves' });
// The PDF gets /PageLabels from the same labels: a viewer numbers its pages i–x, then 1, 2.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Numera los preliminares en mayúsculas

Los romanos en mayúscula salen en versales de la letra de los folios, más altas que las versalitas de los romanos en minúscula.

```diff
-const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
+const pageNumbering = { format: 'upper-roman', startAt: 1 }; // startAt spelled out: cover = I
```

### Numera el libro de corrido

Quita `startAt` y la cuenta sigue en arábigos: la Carta I pasa a ser la página 11, como en los libros que cuentan los preliminares con el texto.

```diff
-:::numbering{format="decimal" startAt=1}
+:::numbering{format="decimal"}
```

### Prescinde del frontispicio

Borra el título `# Frontispiece` y manda la portada a página impar: la página iv queda en blanco, el prólogo sigue siendo la ix y la Carta I, la 1.

```diff
-  { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
+  { id: 'title', ...blind, breakBefore: { enabled: true, parity: 'odd' },
+    advancedDesign: design([ // v: four frontmatter fields, one attribute
```

### Añade sangre y marcas de corte para imprenta

Con `cutLines`, la cubierta y la apertura, ancladas a la sangre, rebasan el corte en 3 mm; los folios y el número de páginas no cambian ([marcas de corte](/es/docs/configuration#marcas-de-corte)).

```diff
     backgroundColor: col('paper'), pageNumbering,
+    cutLines: { enabled: true }, // a 3 mm bleed and crop marks
```

## Errores frecuentes

- **:::numbering se aplica en la página siguiente.** :::numbering cambia la cuenta a partir de la siguiente página que empieza, no de la actual. Colócalo justo después de un :::pagebreak (con paridad odd antes del primer capítulo) para que la numeración nueva empiece donde quieres.
- **Un estilo de título hereda el salto de página de su nivel.** Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }.
- **Un título que no imprime nada necesita un diseño con un elemento.** Un título pensado solo para la estructura (un colofón o una dedicatoria) se sigue imprimiendo: uno a todo el ancho recibe una apertura sintetizada con {titleText} y uno en columna, su texto. Dale un diseño avanzado con un único elemento invisible, como una caja de 0,1 pt.
- **La página 1 es impar: planifica con números físicos.** La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue.
- **Listas 'arabic', recursos 'roman-upper', páginas 'upper-roman'.** Cada ajuste de numeración escribe sus formatos a su manera: las listas usan numberFormat 'arabic' ('decimal' imprime «undefined»), los tipos de recurso counterFormat 'roman-upper' y las páginas y :::numbering 'upper-roman'.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Un estilo de párrafo no tiene color de cursiva.** En postext 1.4.1 un estilo de párrafo fija color y boldColor, pero no italicColor: sus cursivas toman bodyText.italicColor. Un estilo atenuado (la letra pequeña, la línea «Fuente:» de una tabla) imprime sus títulos en cursiva más oscuros que el texto que los rodea. Deja esos estilos en el color del texto o evita en ellos las cursivas.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **El PDF pide todos los pesos y estilos de cada familia.** renderToPdf pide al proveedor de fuentes la negrita, la cursiva y la negrita cursiva de cada familia que un bloque podría usar, aunque nunca se imprima, y un solo rechazo detiene la exportación. El proveedor debe ajustarse al peso más cercano que tenga la familia y volver a la redonda cuando no haya cursiva.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **Una página toma la cabecera de la última sección que empieza en ella.** Cuando dos estilos de título empiezan en la misma página, esta toma la cabecera, el pie y la paleta del último. Una página en blanco de separación pertenece al capítulo anterior, y el relleno de paridad al siguiente.
- **Aviso de maquetación: Cascada de paridad** (`parityCascade`). Los saltos con paridad se acumulan y producen más de dos páginas en blanco seguidas. Solución: Revisa los saltos always-odd y las paridades de breakBefore; en capítulos cortos, la paridad any suele evitar esas páginas en blanco. ([Documentación](https://postext.dev/es/docs/configuration.md#saltar-antes))
- **Aviso de maquetación: Formato de numeración no válido** (`numberingInvalidFormat`). El formato de un :::numbering no es ninguno de decimal, lower-roman, upper-roman, lower-alpha o upper-alpha. Solución: Usa uno de los cinco formatos de número de página; las listas y los recursos los escriben de otro modo. ([Documentación](https://postext.dev/es/docs/document-format.md#numbering))

- Las hojas preliminares heredan el salto `'odd'` del nivel 1. Si no lo tocas, cada una espera a una página impar. Los preliminares pasan entonces de diez páginas a catorce, con cuatro pares más en blanco, y el frontispicio queda frente a una página vacía en vez de frente a la portada. Pon en cada estilo su propio salto con `parity: 'any'`, como hace esta receta, o quítalo con `breakBefore: { enabled: false }`, porque un título con `span: 'page'` abre página de todos modos.
- Quita el `:::pagebreak` de delante de `:::numbering` si el nivel 1 salta con `'always-odd'`, el valor por defecto del H1 en el motor. Si el texto anterior acaba en página impar, el salto de página rellena hasta la impar siguiente, y luego el título añade su página de separación y vuelve a rellenar, con lo que quedan tres páginas en blanco seguidas en lugar de una. Tras un salto `'odd'`, como aquí, el `:::pagebreak` no cambia nada; tras `'any'`, es lo que pone la página 1 en impar.
- Si el prólogo crece una página, la Carta I se va dos páginas más allá, porque el salto a impar le añade delante una página par en blanco. Revisa el número de páginas cada vez que toques los preliminares.
- Mantén los textos del diseño de una apertura por encima del pie de la caja de texto. En Postext 1.4.1, un diseño de título que baja de ahí pierde toda la altura reservada, `minHeight` incluido. En la cubierta no se nota, porque no la sigue ningún texto; en la apertura de un capítulo, el texto empezaría justo debajo del título.
- Los puntos guía del índice usan la letra y el color de los números de página, y `toc.leader` no tiene color propio, así que aquí salen en azul acero.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Frankenstein; or, The Modern Prometheus (1818): la dedicatoria, la Carta I, una frase de la Carta IV (el pie del frontispicio) y otra del volumen I, capítulo VI (citada en un comentario del código): Mary Shelley ([fuente](https://www.gutenberg.org/ebooks/41445)), dominio público
- Texto: El prólogo sin firma de la edición de 1818 (según la atribución habitual): Percy Bysshe Shelley ([fuente](https://www.gutenberg.org/ebooks/41445)), dominio público
- Texto: Paradise Lost (1667), libro X, versos 743–745: el epígrafe de las primeras hojas de 1818: John Milton ([fuente](https://www.gutenberg.org/ebooks/26)), dominio público
- Tipografías: Fanwood Text (OFL-1.1), Playfair Display SC (OFL-1.1), Cinzel (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 019 · Partes en color con un solo atributo](https://postext.dev/es/cookbook/parts-in-colour.md): En esta guía de campo, cada :::part redefine un color de la paleta, que tiñe su portadilla, el dorso pintado, la pestaña, las negritas y su fila del índice. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 015 · Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md): Cada verso es un párrafo, y el que no cabe sigue con 4 em de sangría francesa. Los espacios eme guardan las sangrías de 1918; :::space separa las estrofas. · Nivel 2 (Intermedio) · Poesía
- [N.º 023 · Portada de revista e índice por secciones](https://postext.dev/es/cookbook/magazine-cover-and-contents.md): Una portada cuyas llamadas cuelgan del nombre de la revista y un sumario generado con una fila de color por sección. Cada sección es una parte sin portadilla. · Nivel 3 (Avanzado) · Revistas y fanzines
