# Un muestrario con cada hoja en su papel

> Cada hoja del muestrario de un almacén de papel va en un bloque :::paper, y postext-folio la dibuja con el tono, el gramaje y la rigidez de ese papel.

- Versión HTML: https://postext.dev/es/cookbook/paper-swatch-book
- Receta N.º 101 · Salida e integración · Nivel 2 (Intermedio) · Salidas: Canvas, Controles en vivo
- Géneros: Catálogos
- Requiere postext ≥ 1.13.4 · probada con 1.13.4 el 2026-10-04
- Páginas: [1](https://postext.dev/cookbook/paper-swatch-book/en/p01.webp?v=19630f35), [2](https://postext.dev/cookbook/paper-swatch-book/en/p02.webp?v=19630f35), [3](https://postext.dev/cookbook/paper-swatch-book/en/p03.webp?v=19630f35), [4](https://postext.dev/cookbook/paper-swatch-book/en/p04.webp?v=19630f35), [5](https://postext.dev/cookbook/paper-swatch-book/en/p05.webp?v=19630f35), [6](https://postext.dev/cookbook/paper-swatch-book/en/p06.webp?v=19630f35), [7](https://postext.dev/cookbook/paper-swatch-book/en/p07.webp?v=19630f35), [8](https://postext.dev/cookbook/paper-swatch-book/en/p08.webp?v=19630f35), [9](https://postext.dev/cookbook/paper-swatch-book/en/p09.webp?v=19630f35), [10](https://postext.dev/cookbook/paper-swatch-book/en/p10.webp?v=19630f35), [11](https://postext.dev/cookbook/paper-swatch-book/en/p11.webp?v=19630f35), [12](https://postext.dev/cookbook/paper-swatch-book/en/p12.webp?v=19630f35)
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=paper-swatch-book&lang=en (.postext: https://postext.dev/cookbook/paper-swatch-book/en/paper-swatch-book.postext)
- Última actualización: 2026-10-04
- Otros idiomas: [en](https://postext.dev/en/cookbook/paper-swatch-book.md), [zh](https://postext.dev/zh/cookbook/paper-swatch-book.md)

## En pocas palabras

Un pequeño libro de muestras de una tienda de papel. Cada página dice en qué papel está impresa, y en el visor 3D cada página se ve y se pasa como ese papel.

## Lo que vas a componer

*The Stock Book* de Harwell & Daughters, un almacén de papel ficticio de Bristol: doce páginas de 120 × 176 mm, cubierta de cartulina y cuatro hojas que describen cada una el papel en el que están impresas. El anverso de cada hoja es la ficha del papel, con gramaje, mano, acabado, tono y opacidad; el reverso, una página de texto compuesta como se usa ese papel: una novela, una carta, un pie de lámina, un glosario. Planas en la pantalla, las doce páginas se imprimen igual. En Folio cada hoja tiene su papel: el ahuecado crema, el verjurado con sus corondeles, el estucado brillo que gira plano y rígido y el biblia de 40 g que cae enseguida y deja ver el reverso.

**Esta receta responde a:**

- ¿Cómo imprimo una cubierta, un encarte o un pliego de láminas en un papel distinto del resto del libro?
- ¿Cómo muestro mi documento como un libro en 3D cuyas páginas el lector pasa con la mano?

## La respuesta corta

```js
// script.js, líneas 351–364
const stocks = doc.pages.map((page) => page.paper?.type ?? 'the book’s paper');
const stage = document.createElement('section');
stage.id = 'folio';
stage.ariaLabel = 'The stock book in 3D';
stage.style.cssText = 'height: min(78vh, 720px); margin: 0 auto; max-width: 1280px';
document.getElementById('pages').before(stage);
createFolioFromDocument(stage, doc, {
  appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' },
  alt: (i) => `Page ${i + 1}, printed on ${stocks[i]}`,
  // page.paper is the fence's attributes as written: { type: 'bible', grammage: 40 }.
  onChange: ({ pages }) => kitStatus(pages.map((i) => `p. ${i + 1}: ${stocks[i]}`
    + (doc.pages[i].paper?.grammage ? ` ${doc.pages[i].paper.grammage} g/m²` : ''))
    .join(' · ')),
});
```

## Ingredientes

**Enseña**

- [Otro papel para una tanda de páginas](https://postext.dev/es/docs/document-format.md#paper): Un contenedor :::paper imprime sus páginas en un papel propio: un pliego de láminas en estucado brillo, una cubierta en cartulina, unas hojas de color.
- [Visor de libro en 3D (Folio)](https://postext.dev/es/docs/configuration.md#un-libro-en-3d-postext-folio): postext-folio abre un documento compuesto como un libro impreso sobre una mesa: pliegos según la regla del recto y hojas que el lector pasa con la mano.

**También usa**

- [Papel, encuadernación, mesa y luz (Folio)](https://postext.dev/es/docs/configuration.md#visor-folio-configuración)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [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)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Muestras de color](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak)
- [Geometría por sección](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)

**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), `folio`, [`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)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`createFolioFromDocument`](https://postext.dev/es/docs/configuration.md#un-libro-en-3d-postext-folio), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Crimson Pro (OFL-1.1), Libre Caslon Display (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)

## Elaboración

### 1 · Un bloque por hoja

```js
// script.js, líneas 138–335
// :::paper{type=bookWove grammage=80}
// ## Book Wove, Natural {kicker="Stock 1 of 4"}
// … the stock card, a :::pagebreak, the specimen page …
// :::
// A fence starts and ends on a new page; each run here fills one leaf, recto and verso
// (gotcha: paper-run-whole-leaves). Canvas, HTML and PDF ignore the stock; Folio draws it.
const markdown = String.raw`---
title: "The Stock Book"
subtitle: "Four papers, each printed on itself"
author: "Harwell & Daughters, Paper Merchants"
---

:::paper{type=cardStock grammage=300 shade=#f7f3ea}

# The Stock Book {style="cover" kicker="Harwell & Daughters · Paper Merchants" season="Autumn 2026 · Four stocks on four leaves"}

# How to use this book {kicker="Before you begin"}

Every leaf in this book is printed on the paper it describes. The front of each leaf names the stock and gives its numbers; the back carries a page of type set the way that paper is most often used, so you can judge it as a reader will meet it.

Turn the leaves slowly. A heavy sheet turns in a wide, stiff curve, and a light one falls over almost at once. Hold a page up to the window and you will see how much of the reverse shows through. Run a thumb along the edge and you will feel the coating, or the lack of it.

The numbers on each card are the ones a printer asks for. Weight is in grams per square metre. Bulk, in cubic centimetres per gram, says how much thickness a gram of the paper gives you: multiply the two and you have the caliper of a single sheet in microns, and from that the spine of a book.

The covers are our house board, a 300 g uncoated card that takes a crease without cracking. We use it for the covers of this book and for most of the menus and programmes our customers print with us.

:::

:::paper{type=bookWove grammage=80}

# Book Wove, Natural {kicker="Stock 1 of 4"}

:::paragraphs{style="spec"}
**Weight** 80 g/m²

**Bulk** 1.6 cm³/g · a sheet of 128 µm

**Finish** uncoated, with a soft wove surface

**Shade** natural, a warm cream

**Opacity** 94 %
:::

A bulky, lightweight sheet made for novels. It is thicker than its weight suggests, so a short book still feels substantial in the hand, and its cream shade is kinder to the eye over a long evening than a bright white.

The wove surface comes from the woven wire the sheet forms on: an even, faint texture you see only at a low angle. Ink sits into it rather than on it, and type keeps a little softness at its edges.

**Best for** fiction, memoir, poetry, any book that is read rather than consulted.

:::pagebreak

## Specimen

The ferry left the north bank at six every evening, whatever the weather, and for thirty-one years Tobias Wren had written its passengers into a ledger bound in green cloth. He wrote the date, the hour and the state of the river, and then a line for every soul who came aboard: a name if they gave one, a description if they did not.

The river at Hallam is four hundred yards wide and never still. It comes down from the moors brown with peat, carrying branches and once, in the flood of the year his daughter was born, the roof of a barn with a goat standing on it. Tobias took the goat across for nothing and wrote it down as a passenger.

On the evening of the last crossing the light was going early. October had turned cold all at once, and the willows on the south bank had dropped their leaves in a single night, so that the landing stage looked bare and new.

:::

:::paper{type=uncoated grammage=100 texture=laid textureStrength=1.5 shade=#fbf8f1}

# Laid Writing, White {kicker="Stock 2 of 4"}

:::paragraphs{style="spec"}
**Weight** 100 g/m²

**Bulk** 1.25 cm³/g · a sheet of 125 µm

**Finish** uncoated, with laid and chain lines

**Shade** a soft, unbleached white

**Opacity** 96 %
:::

A writing paper with a ribbed surface. Close laid lines run across the sheet and wider chain lines cross them, the pattern a dandy roll presses into the wet web. Tilt the page under a lamp and the ribs catch the light.

It is a paper for letters and announcements more than for books: firm enough to stand in an envelope, rough enough to take a fountain pen without feathering, and quiet enough to print a single paragraph on and leave the rest of the sheet empty.

**Best for** letterheads, invitations, certificates, the endpapers of a fine edition.

:::pagebreak

## Specimen

*Harwell & Daughters, 14 Rope Walk, Bristol*

Dear Mrs Okafor,

Thank you for your letter and for the proofs of the wedding stationery, which arrived this morning in perfect order. We agree that the laid sheet suits the invitation far better than the smooth one; the lines give the lettering something to rest on.

We have set aside two reams from the same making, so that the reply cards and the order of service will match the invitations exactly. Papers from different makings can differ by a shade, and on a table set side by side the difference shows.

The envelopes will follow on Thursday. If you would like the flaps lined, we keep a tissue in four colours and would be glad to send samples.

With our best wishes for the day,

*Ruth Harwell*

:::

:::paper{type=coatedGloss grammage=150 showThrough=false}

# Art Gloss, Bright White {kicker="Stock 3 of 4"}

:::paragraphs{style="spec"}
**Weight** 150 g/m²

**Bulk** 0.8 cm³/g · a sheet of 120 µm

**Finish** coated both sides, calendered to a gloss

**Shade** bright white

**Opacity** 98 %
:::

A heavy coated sheet, polished between steel rollers until it shines. The clay coating fills the hollows between the fibres, so ink stays on the surface and dries as a sharp, saturated dot. Photographs come up with deep blacks and clean highlights.

Because the coating is calendered hard, the sheet is thinner for its weight than an uncoated one, and noticeably stiffer. It turns as a single flat plane and lands with a sound. Nothing shows through from the reverse.

**Best for** art books, exhibition catalogues, plate sections, brochures.

:::pagebreak

## Specimen

*Plate 14.* Evening light on the estuary at Pill, looking west towards the two bridges. The photograph was taken from the old pilots’ steps an hour before high water, when the mud is still uncovered and holds the colour of the sky.

*Plate 15.* A barge loaded with Norwegian timber passes under the suspension bridge in 1931. Glass negative, the Harwell family collection; printed here at the size of the original plate.

On this paper the four process inks print as they will in the book: :swatch{color="cyan"} cyan, :swatch{color="magenta"} magenta, :swatch{color="yellow"} yellow and :swatch{color="key"} black, side by side and never muddied by the sheet.

Ask for the plate section of a book to be printed on gloss and bound in among the uncoated pages of the text: the reader will feel the change of paper at every turn.

:::

:::paper{type=bible grammage=40}

# India Bible, Opaque {kicker="Stock 4 of 4"}

:::paragraphs{style="spec"}
**Weight** 40 g/m²

**Bulk** 1.1 cm³/g · a sheet of 44 µm

**Finish** uncoated, with a fine vellum tooth

**Shade** an ivory white

**Opacity** 82 %
:::

The thinnest sheet we stock that will still go through a book press. A thousand pages of it make a spine of under five centimetres, which is why dictionaries, prayer books and the collected works of the long-winded are printed on it.

It is strong for its weight, but it is not opaque. Hold this leaf up and you will read the type on its back through the sheet. Good setting on a thin paper prints the lines of both sides in register, so that the show-through falls between the lines and not across them.

**Best for** dictionaries, scriptures, reference works, pocket editions.

:::pagebreak

## Specimen

:::paragraphs{style="entry"}
**caliper** *n.* The thickness of a single sheet of paper or board, measured in microns. From the instrument used to measure it.

**chain lines** *n. pl.* The widely spaced lines of a laid paper, left by the wires that hold the laid wires of the mould in place.

**deckle** *n.* The rough, feathered edge of a handmade sheet, where the pulp ran under the frame of the mould.

**grain** *n.* The direction in which most fibres in a sheet lie. A book folds and opens well when the grain runs parallel to its spine.

**opacity** *n.* The degree to which a sheet hides what is printed on its reverse.

**ream** *n.* Five hundred sheets of paper, once four hundred and eighty.
:::

:::

:::paper{type=cardStock grammage=300 shade=#f7f3ea}

# Ordering {kicker="Harwell & Daughters"}

Every stock in this book is held in our warehouse in full reams and in the sizes most printers use. Samples of any paper are free; send us the job and we will send the sheets. Orders placed before noon leave the same day.

When you write, give the weight and the name as they appear on the cards. If you are matching an earlier job, send us a sheet of it: we will find the making it came from.

:::paragraphs{style="colophon"}
Set in Crimson Pro, Libre Caslon Display and IBM Plex Sans Condensed (SIL Open Font License) · Text: original, CC BY 4.0 · Harwell & Daughters is a fictional merchant.
:::

# Harwell & Daughters {style="back" address="14 Rope Walk · Bristol · since 1897"}

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

Un bloque `:::paper` empieza en página nueva y el texto que lo sigue también, así que aquí cada tanda llena una hoja: la ficha en el recto, un `:::pagebreak` y la muestra en el verso. Una hoja tiene un solo papel para sus dos caras, el de su recto: una tanda que empezara en un verso imprimiría esa página en el papel de la hoja que comparte (mira Errores frecuentes).

### 2 · El papel del libro y sus tapas

```js
// script.js, líneas 41–51
const folio = {
  tilt: 24,
  // The paper of any page outside a :::paper fence (there are none here). Each fence's
  // attributes override these field by field, and past them the stock's defaults apply.
  paper: { type: 'uncoated' },
  // 'pages': page 1 turns as the front board and page 12, a verso, as the back board, so no
  // case is drawn round the block (gotcha: folio-cover-pages-even).
  binding: { type: 'paperback', cover: 'pages', coverMaterial: 'paper' },
  surface: { type: 'linen' },
  lighting: { environment: 'daylight', intensity: 1 },
};
```

`folio.paper` es el papel de toda página fuera de un bloque; los atributos de un bloque lo sustituyen campo a campo, y lo demás sale de los valores del tipo de papel. `cover: 'pages'` hace de la página 1 la tapa delantera y de la 12 la trasera, de modo que el libro está cerrado hasta que lo abres; para eso el número de páginas tiene que ser par, y el salto `parity: 'even'` de la contracubierta lo garantiza.

### 3 · Cubiertas a sangre

```js
// script.js, líneas 55–84
const field = { kind: 'box', id: 'field', style: { backgroundColor: col('oxblood') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(TRIM_H) } } };
const front = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7.5), color: col('cream') },
    at('page', 'top-left', INNER, 22)),
  text('title', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(52),
    lineHeight: 0.95, color: col('cream'), align: 'left' },
  { ...at('#kicker', 'below', 0, 30), size: { width: mm(TRIM_W - INNER - OUTER) } }),
  text('subtitle', '{subtitle}', { fontFamily: 'Crimson Pro', italic: true,
    fontSize: pt(12), lineHeight: 1.3, color: col('cream'), align: 'left' },
  { ...at('#title', 'below', 0, 6), size: { width: mm(80) } }),
  text('season', '{attr.season}', { ...label, fontSize: pt(7), color: col('cream') },
    at('page', 'bottom-left', INNER, -18)),
] } };
const back = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(20),
    lineHeight: 1, color: col('cream'), align: 'center' },
  { ...at('page', 'center', 0, -4), size: { width: mm(90) } }),
  text('address', '{attr.address}', { ...label, fontSize: pt(6.8), color: col('cream'),
    align: 'center' }, { ...at('#name', 'below', 0, 4), size: { width: mm(90) } }),
] } };
// The covers are the book's two openers; the folio skips them (pages: 'body', below).
// No margins: the design's column is the whole page, so the field reaches every edge, and the
// style's margins last only as long as its section, the cover page.
const flush = { span: 'page', margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) } };
const headingStyles = [
  { id: 'cover', numbered: false, toc: false, advancedDesign: front, ...flush },
  { id: 'back', numbered: false, toc: false, advancedDesign: back, ...flush,
    breakBefore: { enabled: true, parity: 'even' } }, // the last page, a verso
];
```

Cada cubierta es un estilo de título cuyo diseño lleva un campo de color tan grande como el corte. El estilo pone sus márgenes a cero mientras dura su sección, que es solo la página de cubierta, así que el campo llega a todos los bordes y las páginas siguientes conservan sus márgenes.

### 4 · La ficha del papel

```js
// script.js, líneas 88–99
const card = { enabled: true, minHeight: mm(25), slot: { elements: [
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7), color: col('oxblood') },
    at('container', 'top-left', 0, 2)),
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(24),
    lineHeight: 1.05, color: col('ink'), align: 'left' },
  { ...at('#kicker', 'below', 0, 2.5), size: { width: 'fill' } }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('#name', 'below', 0, 3.5), size: { width: 'fill' } } },
] } };
const spec = { id: 'spec', fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(8),
  lineHeight: pt(LEAD * 0.85), textAlign: 'left', firstLineIndent: pt(0), color: col('ink'),
  boldColor: col('oxblood') };
```

Cada hoja abre con un título de primer nivel dibujado desde un hueco de diseño: el antetítulo sale de los atributos del título, luego el nombre del papel y un filete fino. Las líneas de especificaciones son un estilo de párrafo en la letra estrecha de las etiquetas, con las etiquetas en negrita y en el color de acento.

### 5 · Lee el papel en la composición

El código es [la respuesta corta](#la-respuesta-corta) de arriba. Cada página compuesta dentro de un bloque lleva los atributos del bloque en `page.paper`, tal como se escribieron: `{ type: 'bible', grammage: 40 }`. `createFolioFromDocument` se los pasa al visor, que dibuja cada hoja con su papel; el pen también los lee para la línea de estado y para el texto alternativo de cada página.

## 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/paper-swatch-book

### script.js

```js
// ═══ Postext Cookbook · Nº 101 · A swatch book, every leaf on its own paper ═══════════
// https://postext.dev/en/cookbook/paper-swatch-book
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Crimson Pro, Libre Caslon Display, IBM Plex Sans Cond. (OFL) · Needs postext ≥ 1.13.4
//
// A paper merchant's stock book: each leaf sits inside a :::paper fence that names its stock,
// and postext-folio draws that leaf with the stock's shade, surface, stiffness and opacity.
// Turn the leaves and compare: the gloss sheet turns flat and stiff, the bible paper falls
// over at once and shows its back through, the card covers turn as boards.
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'paper-swatch-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the house colours, and the four process inks the gloss page shows
const palette = {
  ink: '#22201d', // text
  oxblood: '#5e2128', // the covers' colour field, kickers and folios
  cream: '#f3ead6', // type on the covers
  rule: '#cbc2b3', // hairlines
  muted: '#6f675e', // labels, folios, the colophon
  cyan: '#00a0e0', magenta: '#e0007a', yellow: '#ffe100', key: '#1b1b1b', // process inks
};
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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
// #endregion
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [120, 176, 18, 20, 16, 14]; // mm
const LEAD = 13.4; // pt
const label = { fontFamily: 'IBM Plex Sans Condensed', fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', align: 'left' };
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  placement, ...look });

// #region folio: the book's own paper and binding; the leaves' papers come from the text
const folio = {
  tilt: 24,
  // The paper of any page outside a :::paper fence (there are none here). Each fence's
  // attributes override these field by field, and past them the stock's defaults apply.
  paper: { type: 'uncoated' },
  // 'pages': page 1 turns as the front board and page 12, a verso, as the back board, so no
  // case is drawn round the block (gotcha: folio-cover-pages-even).
  binding: { type: 'paperback', cover: 'pages', coverMaterial: 'paper' },
  surface: { type: 'linen' },
  lighting: { environment: 'daylight', intensity: 1 },
};
// #endregion

// #region covers: a colour field to the bleed, for the front and the back of the book
const field = { kind: 'box', id: 'field', style: { backgroundColor: col('oxblood') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(TRIM_H) } } };
const front = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7.5), color: col('cream') },
    at('page', 'top-left', INNER, 22)),
  text('title', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(52),
    lineHeight: 0.95, color: col('cream'), align: 'left' },
  { ...at('#kicker', 'below', 0, 30), size: { width: mm(TRIM_W - INNER - OUTER) } }),
  text('subtitle', '{subtitle}', { fontFamily: 'Crimson Pro', italic: true,
    fontSize: pt(12), lineHeight: 1.3, color: col('cream'), align: 'left' },
  { ...at('#title', 'below', 0, 6), size: { width: mm(80) } }),
  text('season', '{attr.season}', { ...label, fontSize: pt(7), color: col('cream') },
    at('page', 'bottom-left', INNER, -18)),
] } };
const back = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(20),
    lineHeight: 1, color: col('cream'), align: 'center' },
  { ...at('page', 'center', 0, -4), size: { width: mm(90) } }),
  text('address', '{attr.address}', { ...label, fontSize: pt(6.8), color: col('cream'),
    align: 'center' }, { ...at('#name', 'below', 0, 4), size: { width: mm(90) } }),
] } };
// The covers are the book's two openers; the folio skips them (pages: 'body', below).
// No margins: the design's column is the whole page, so the field reaches every edge, and the
// style's margins last only as long as its section, the cover page.
const flush = { span: 'page', margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) } };
const headingStyles = [
  { id: 'cover', numbered: false, toc: false, advancedDesign: front, ...flush },
  { id: 'back', numbered: false, toc: false, advancedDesign: back, ...flush,
    breakBefore: { enabled: true, parity: 'even' } }, // the last page, a verso
];
// #endregion

// #region stock-card: the kicker over each stock's name, and the specification lines
const card = { enabled: true, minHeight: mm(25), slot: { elements: [
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7), color: col('oxblood') },
    at('container', 'top-left', 0, 2)),
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(24),
    lineHeight: 1.05, color: col('ink'), align: 'left' },
  { ...at('#kicker', 'below', 0, 2.5), size: { width: 'fill' } }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('#name', 'below', 0, 3.5), size: { width: 'fill' } } },
] } };
const spec = { id: 'spec', fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(8),
  lineHeight: pt(LEAD * 0.85), textAlign: 'left', firstLineIndent: pt(0), color: col('ink'),
  boldColor: col('oxblood') };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'en-us',
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Crimson Pro', fontSize: pt(10.2), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4),
    indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: 'Libre Caslon Display', color: col('ink'), fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    // A :::paper fence already starts a new page, so the stock name breaks to any side.
    { level: 1, fontSize: pt(24), breakBefore: { enabled: true, parity: 'any' },
      marginTop: pt(0), marginBottom: pt(LEAD / 2), advancedDesign: card },
    { level: 2, fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
      lineHeight: pt(LEAD), marginTop: pt(0), marginBottom: pt(LEAD / 2) },
  ] },
  headingStyles,
  paragraphStyles: [spec,
    { id: 'entry', fontSize: pt(8.4), lineHeight: pt(11.5), textAlign: 'left',
      firstLineIndent: pt(0), hangingIndent: mm(4), marginTop: pt(3) },
    { id: 'colophon', fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(6.8),
      lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
      marginTop: pt(2 * LEAD) }],
  header: { elements: [] },
  footer: { elements: [text('folio', '{pageNumber} · {title}', { ...label, fontSize: pt(6.5),
    color: col('muted'), align: 'center', pages: 'body' }, at('page', 'bottom', 0, -10))] },
  folio,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region paper-runs: each leaf's stock is a :::paper fence around its two pages
// :::paper{type=bookWove grammage=80}
// ## Book Wove, Natural {kicker="Stock 1 of 4"}
// … the stock card, a :::pagebreak, the specimen page …
// :::
// A fence starts and ends on a new page; each run here fills one leaf, recto and verso
// (gotcha: paper-run-whole-leaves). Canvas, HTML and PDF ignore the stock; Folio draws it.
const markdown = String.raw`---
title: "The Stock Book"
subtitle: "Four papers, each printed on itself"
author: "Harwell & Daughters, Paper Merchants"
---

:::paper{type=cardStock grammage=300 shade=#f7f3ea}

# The Stock Book {style="cover" kicker="Harwell & Daughters · Paper Merchants" season="Autumn 2026 · Four stocks on four leaves"}

# How to use this book {kicker="Before you begin"}

Every leaf in this book is printed on the paper it describes. The front of each leaf names the stock and gives its numbers; the back carries a page of type set the way that paper is most often used, so you can judge it as a reader will meet it.

Turn the leaves slowly. A heavy sheet turns in a wide, stiff curve, and a light one falls over almost at once. Hold a page up to the window and you will see how much of the reverse shows through. Run a thumb along the edge and you will feel the coating, or the lack of it.

The numbers on each card are the ones a printer asks for. Weight is in grams per square metre. Bulk, in cubic centimetres per gram, says how much thickness a gram of the paper gives you: multiply the two and you have the caliper of a single sheet in microns, and from that the spine of a book.

The covers are our house board, a 300 g uncoated card that takes a crease without cracking. We use it for the covers of this book and for most of the menus and programmes our customers print with us.

:::

:::paper{type=bookWove grammage=80}

# Book Wove, Natural {kicker="Stock 1 of 4"}

:::paragraphs{style="spec"}
**Weight** 80 g/m²

**Bulk** 1.6 cm³/g · a sheet of 128 µm

**Finish** uncoated, with a soft wove surface

**Shade** natural, a warm cream

**Opacity** 94 %
:::

A bulky, lightweight sheet made for novels. It is thicker than its weight suggests, so a short book still feels substantial in the hand, and its cream shade is kinder to the eye over a long evening than a bright white.

The wove surface comes from the woven wire the sheet forms on: an even, faint texture you see only at a low angle. Ink sits into it rather than on it, and type keeps a little softness at its edges.

**Best for** fiction, memoir, poetry, any book that is read rather than consulted.

:::pagebreak

## Specimen

The ferry left the north bank at six every evening, whatever the weather, and for thirty-one years Tobias Wren had written its passengers into a ledger bound in green cloth. He wrote the date, the hour and the state of the river, and then a line for every soul who came aboard: a name if they gave one, a description if they did not.

The river at Hallam is four hundred yards wide and never still. It comes down from the moors brown with peat, carrying branches and once, in the flood of the year his daughter was born, the roof of a barn with a goat standing on it. Tobias took the goat across for nothing and wrote it down as a passenger.

On the evening of the last crossing the light was going early. October had turned cold all at once, and the willows on the south bank had dropped their leaves in a single night, so that the landing stage looked bare and new.

:::

:::paper{type=uncoated grammage=100 texture=laid textureStrength=1.5 shade=#fbf8f1}

# Laid Writing, White {kicker="Stock 2 of 4"}

:::paragraphs{style="spec"}
**Weight** 100 g/m²

**Bulk** 1.25 cm³/g · a sheet of 125 µm

**Finish** uncoated, with laid and chain lines

**Shade** a soft, unbleached white

**Opacity** 96 %
:::

A writing paper with a ribbed surface. Close laid lines run across the sheet and wider chain lines cross them, the pattern a dandy roll presses into the wet web. Tilt the page under a lamp and the ribs catch the light.

It is a paper for letters and announcements more than for books: firm enough to stand in an envelope, rough enough to take a fountain pen without feathering, and quiet enough to print a single paragraph on and leave the rest of the sheet empty.

**Best for** letterheads, invitations, certificates, the endpapers of a fine edition.

:::pagebreak

## Specimen

*Harwell & Daughters, 14 Rope Walk, Bristol*

Dear Mrs Okafor,

Thank you for your letter and for the proofs of the wedding stationery, which arrived this morning in perfect order. We agree that the laid sheet suits the invitation far better than the smooth one; the lines give the lettering something to rest on.

We have set aside two reams from the same making, so that the reply cards and the order of service will match the invitations exactly. Papers from different makings can differ by a shade, and on a table set side by side the difference shows.

The envelopes will follow on Thursday. If you would like the flaps lined, we keep a tissue in four colours and would be glad to send samples.

With our best wishes for the day,

*Ruth Harwell*

:::

:::paper{type=coatedGloss grammage=150 showThrough=false}

# Art Gloss, Bright White {kicker="Stock 3 of 4"}

:::paragraphs{style="spec"}
**Weight** 150 g/m²

**Bulk** 0.8 cm³/g · a sheet of 120 µm

**Finish** coated both sides, calendered to a gloss

**Shade** bright white

**Opacity** 98 %
:::

A heavy coated sheet, polished between steel rollers until it shines. The clay coating fills the hollows between the fibres, so ink stays on the surface and dries as a sharp, saturated dot. Photographs come up with deep blacks and clean highlights.

Because the coating is calendered hard, the sheet is thinner for its weight than an uncoated one, and noticeably stiffer. It turns as a single flat plane and lands with a sound. Nothing shows through from the reverse.

**Best for** art books, exhibition catalogues, plate sections, brochures.

:::pagebreak

## Specimen

*Plate 14.* Evening light on the estuary at Pill, looking west towards the two bridges. The photograph was taken from the old pilots’ steps an hour before high water, when the mud is still uncovered and holds the colour of the sky.

*Plate 15.* A barge loaded with Norwegian timber passes under the suspension bridge in 1931. Glass negative, the Harwell family collection; printed here at the size of the original plate.

On this paper the four process inks print as they will in the book: :swatch{color="cyan"} cyan, :swatch{color="magenta"} magenta, :swatch{color="yellow"} yellow and :swatch{color="key"} black, side by side and never muddied by the sheet.

Ask for the plate section of a book to be printed on gloss and bound in among the uncoated pages of the text: the reader will feel the change of paper at every turn.

:::

:::paper{type=bible grammage=40}

# India Bible, Opaque {kicker="Stock 4 of 4"}

:::paragraphs{style="spec"}
**Weight** 40 g/m²

**Bulk** 1.1 cm³/g · a sheet of 44 µm

**Finish** uncoated, with a fine vellum tooth

**Shade** an ivory white

**Opacity** 82 %
:::

The thinnest sheet we stock that will still go through a book press. A thousand pages of it make a spine of under five centimetres, which is why dictionaries, prayer books and the collected works of the long-winded are printed on it.

It is strong for its weight, but it is not opaque. Hold this leaf up and you will read the type on its back through the sheet. Good setting on a thin paper prints the lines of both sides in register, so that the show-through falls between the lines and not across them.

**Best for** dictionaries, scriptures, reference works, pocket editions.

:::pagebreak

## Specimen

:::paragraphs{style="entry"}
**caliper** *n.* The thickness of a single sheet of paper or board, measured in microns. From the instrument used to measure it.

**chain lines** *n. pl.* The widely spaced lines of a laid paper, left by the wires that hold the laid wires of the mould in place.

**deckle** *n.* The rough, feathered edge of a handmade sheet, where the pulp ran under the frame of the mould.

**grain** *n.* The direction in which most fibres in a sheet lie. A book folds and opens well when the grain runs parallel to its spine.

**opacity** *n.* The degree to which a sheet hides what is printed on its reverse.

**ream** *n.* Five hundred sheets of paper, once four hundred and eighty.
:::

:::

:::paper{type=cardStock grammage=300 shade=#f7f3ea}

# Ordering {kicker="Harwell & Daughters"}

Every stock in this book is held in our warehouse in full reams and in the sizes most printers use. Samples of any paper are free; send us the job and we will send the sheets. Orders placed before noon leave the same day.

When you write, give the weight and the name as they appear on the cards. If you are matching an earlier job, send us a sheet of it: we will find the making it came from.

:::paragraphs{style="colophon"}
Set in Crimson Pro, Libre Caslon Display and IBM Plex Sans Condensed (SIL Open Font License) · Text: original, CC BY 4.0 · Harwell & Daughters is a fictional merchant.
:::

# Harwell & Daughters {style="back" address="14 Rope Walk · Bristol · since 1897"}

:::
`; // content.<lang>.md, inlined by the Cookbook
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Crimson Pro': ['400', '400i', '700'],
  'Libre Caslon Display': ['400'],
  'IBM Plex Sans Condensed': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: 'The Stock Book · four papers in Folio' });

// #region answer: every page carries its stock; the viewer draws each leaf with it
const stocks = doc.pages.map((page) => page.paper?.type ?? 'the book’s paper');
const stage = document.createElement('section');
stage.id = 'folio';
stage.ariaLabel = 'The stock book in 3D';
stage.style.cssText = 'height: min(78vh, 720px); margin: 0 auto; max-width: 1280px';
document.getElementById('pages').before(stage);
createFolioFromDocument(stage, doc, {
  appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' },
  alt: (i) => `Page ${i + 1}, printed on ${stocks[i]}`,
  // page.paper is the fence's attributes as written: { type: 'bible', grammage: 40 }.
  onChange: ({ pages }) => kitStatus(pages.map((i) => `p. ${i + 1}: ${stocks[i]}`
    + (doc.pages[i].paper?.grammage ? ` ${doc.pages[i].paper.grammage} g/m²` : ''))
    .join(' · ')),
});
// #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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Añade un pliego de láminas en estucado más grueso

Un bloque dentro del texto de cualquier libro imprime esas páginas en su propio papel; las figuras citadas dentro se colocan antes de que se cierre.

```diff
+:::paper{type=coatedGloss grammage=170 showThrough=false}
+
+# Plates {kicker="Between pages 64 and 65"}
+
+::resource{id="plate-1"}
+
+:::
```

### Un cuadernillo grapado con cubierta de cartulina

[La receta del fanzine](https://postext.dev/es/cookbook/saddle-stitched-card-cover.md) envuelve páginas de papel prensa en un pliego de cubierta de cartulina con dos bloques y un grapado a caballete.

## Errores frecuentes

- **Una tanda de papel empieza y acaba en página nueva, y la hoja toma el papel de su recto.** El texto dentro de :::paper empieza en página nueva, y el que le sigue también. Una hoja tiene dos páginas y un solo papel, el de su recto, así que una tanda que empieza en un verso imprime esa página en el papel de la hoja que comparte. Pon un salto de página donde una tanda deba empezar en recto.
- **Cover: 'pages' necesita un número par de páginas para la tapa trasera.** Con binding.cover en 'pages' la primera página gira como tapa delantera, y la última como tapa trasera solo si cae en un verso. Con un número impar de páginas el libro tiene tapa delantera y no trasera: complétalo hasta un número par o deja la caja.
- **El visor Folio toma el tamaño de su contenedor.** createFolioFromDocument encaja el libro en el elemento que le das, con los botones y el contador de páginas en sus márgenes. Un elemento sin altura (un div vacío en el flujo normal) no muestra nada: dale una altura en CSS.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Crimson Pro (OFL-1.1), Libre Caslon Display (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 103 · Un banco de pruebas para los ajustes de Folio](https://postext.dev/es/cookbook/folio-settings-bench.md): Un formulario con cada ajuste de config.folio maneja un visor postext-folio con setAppearance: papel, encuadernación, tapas, mesa y luz cambian al vuelo. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
- [N.º 026 · Muestrario con todas sus fuentes cargadas antes de componer](https://postext.dev/es/cookbook/fonts-before-layout.md): Un muestrario de cuatro páginas cuya primera composición nombra las fuentes que usa; el pen las carga, vacía la caché de anchos y vuelve a componer. · Nivel 2 (Intermedio) · Catálogos
- [N.º 034 · Fichas de catálogo frente a sus láminas](https://postext.dev/es/cookbook/catalogue-facing-plates.md): Cada ficha abre página par y cita su lámina al empezar el comentario; la lámina flota a la impar de enfrente con 221 mm de alto y el ancho que da su proporción. · Nivel 3 (Avanzado) · Catálogos
