# Um mostruário com cada folha no seu papel

> Cada folha do mostruário de uma distribuidora de papel fica num bloco :::paper, e o postext-folio a desenha com o tom, a gramatura e a rigidez desse papel.

- Versão HTML: https://postext.dev/pt/cookbook/paper-swatch-book
- Receita Nº 101 · Saída e integração · Nível 2 (Intermediário) · Saídas: Canvas, Controles ao vivo
- Gêneros: Catálogos
- Requer postext ≥ 1.13.4 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/paper-swatch-book/en/p01.webp?v=96497d59), [2](https://postext.dev/cookbook/paper-swatch-book/en/p02.webp?v=96497d59), [3](https://postext.dev/cookbook/paper-swatch-book/en/p03.webp?v=96497d59), [4](https://postext.dev/cookbook/paper-swatch-book/en/p04.webp?v=96497d59), [5](https://postext.dev/cookbook/paper-swatch-book/en/p05.webp?v=96497d59), [6](https://postext.dev/cookbook/paper-swatch-book/en/p06.webp?v=96497d59), [7](https://postext.dev/cookbook/paper-swatch-book/en/p07.webp?v=96497d59), [8](https://postext.dev/cookbook/paper-swatch-book/en/p08.webp?v=96497d59), [9](https://postext.dev/cookbook/paper-swatch-book/en/p09.webp?v=96497d59), [10](https://postext.dev/cookbook/paper-swatch-book/en/p10.webp?v=96497d59), [11](https://postext.dev/cookbook/paper-swatch-book/en/p11.webp?v=96497d59), [12](https://postext.dev/cookbook/paper-swatch-book/en/p12.webp?v=96497d59)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=paper-swatch-book&lang=en (.postext: https://postext.dev/cookbook/paper-swatch-book/en/paper-swatch-book.postext)
- Última atualização: 2026-10-04
- Outros idiomas: [en](https://postext.dev/en/cookbook/paper-swatch-book.md), [es](https://postext.dev/es/cookbook/paper-swatch-book.md), [ca](https://postext.dev/ca/cookbook/paper-swatch-book.md), [zh](https://postext.dev/zh/cookbook/paper-swatch-book.md), [ja](https://postext.dev/ja/cookbook/paper-swatch-book.md), [ar](https://postext.dev/ar/cookbook/paper-swatch-book.md)

## Em poucas palavras

Um livrinho de amostras de uma loja de papel. Cada página diz em que papel está impressa, e no visualizador 3D cada página tem a cara e o jeito de virar desse papel.

## O que você vai compor

*The Stock Book* da Harwell & Daughters, uma distribuidora de papel fictícia de Bristol: doze páginas em formato 120 × 176 mm, capa de cartão e quatro folhas que descrevem, cada uma, o papel em que estão impressas. A frente de cada folha é a ficha do papel, com gramatura, espessura, acabamento, tom e opacidade; o verso é uma página de texto composta do jeito que esse papel costuma ser usado: um romance, uma carta, uma legenda de galeria, um glossário. Planas na tela, as doze páginas saem iguais. No Folio cada folha tem seu papel: o offset creme, o vergê com suas linhas d'água, o couché brilho que vira plano e rígido e o bíblia de 40 g que tomba na hora e deixa ver o verso por transparência.

**Esta receita responde a:**

- Como faço para imprimir uma capa, um encarte ou um caderno de pranchas num papel diferente do resto do livro?
- Como mostro meu documento como um livro em 3D cujas páginas o leitor vira com a mão?

## A resposta curta

```js
// script.js, linhas 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

**Ensina**

- [Outro papel para uma sequência de páginas](https://postext.dev/pt/docs/document-format.md#paper): Um contêiner :::paper imprime as páginas que contém em um papel próprio: um caderno de pranchas em couché brilho, uma capa em cartão, algumas folhas coloridas.
- [Visualizador de livro em 3D (Folio)](https://postext.dev/pt/docs/configuration.md#um-livro-em-3d-postext-folio): postext-folio abre um documento diagramado como um livro impresso sobre uma mesa: páginas duplas montadas pela regra do recto e folhas que o leitor vira com a mão.

**Também usa**

- [Papel, encadernação, mesa e luz (Folio)](https://postext.dev/pt/docs/configuration.md#visualizador-folio-configuração)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Amostras de cor](https://postext.dev/pt/docs/document-format.md#formatação-em-linha)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Bibliografias e glossários](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [Geometria por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Capítulos sem número](https://postext.dev/pt/docs/configuration.md#estilos-de-título)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), `folio`, [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`createFolioFromDocument`](https://postext.dev/pt/docs/configuration.md#um-livro-em-3d-postext-folio), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

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

## Preparo

### 1 · Um bloco por folha

```js
// script.js, linhas 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
```

Um bloco `:::paper` começa numa página nova e o texto que vem depois dele também, então aqui cada trecho preenche uma folha: a ficha no anverso, um `:::pagebreak` e a amostra no verso. Uma folha tem um só papel para os dois lados, o do seu anverso: um trecho que começasse num verso imprimiria essa página no papel da folha a que ela pertence (veja Erros comuns).

### 2 · O papel do livro e as capas

```js
// script.js, linhas 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` é o papel de toda página fora de um bloco; os atributos de um bloco o substituem campo a campo, e o resto vem dos valores padrão do tipo de papel. `cover: 'pages'` faz da página 1 a capa e da página 12 a quarta capa, de modo que o livro fica fechado até você abri-lo; para isso o número de páginas precisa ser par, e a quebra `parity: 'even'` da quarta capa garante isso.

### 3 · Capas com sangria

```js
// script.js, linhas 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 capa é um estilo de título cujo design traz um campo de cor do tamanho do refile. O estilo zera as margens enquanto dura a sua seção, que é só a página da capa, então o campo chega a todas as bordas e as páginas seguintes mantêm as margens.

### 4 · A ficha do papel

```js
// script.js, linhas 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 folha abre com um título de nível 1 desenhado a partir de um slot: o antetítulo, que vem dos atributos do título, o nome do papel e um fio fino. As linhas de especificações são um estilo de parágrafo na letra condensada dos rótulos, com os rótulos em negrito na cor de destaque.

### 5 · Leia o papel de volta na diagramação

O código é [a resposta curta](https://postext.dev/pt/cookbook/paper-swatch-book.md#a-resposta-curta) logo acima. Toda página diagramada dentro de um bloco carrega os atributos do bloco em `page.paper`, tal como foram escritos: `{ type: 'bible', grammage: 40 }`. `createFolioFromDocument` os repassa ao visualizador, que desenha cada folha com o seu papel; o pen também os lê para a linha de status e para o texto alternativo de cada página.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** The family's Fontsource metadata (weights, styles, subsets), 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
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. */
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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Acrescente um caderno de pranchas em couché mais grosso

Um bloco no meio do texto de qualquer livro imprime aquelas páginas no papel dele; as figuras citadas lá dentro são posicionadas antes de o bloco fechar.

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

### Um livreto grampeado com capa de cartão

[A receita do fanzine](https://postext.dev/pt/cookbook/saddle-stitched-card-cover.md) envolve páginas de papel-jornal numa folha de capa de cartão com dois blocos e um grampeamento a cavalo.

## Erros comuns

- **Um trecho em outro papel começa e termina em página nova, e a folha usa o papel da sua frente.** O texto dentro de :::paper começa em página nova, e o texto que vem depois também. Uma folha tem duas páginas e um só papel, o da sua frente, então um trecho que começa em uma página par imprime essa página no papel da folha que ela compartilha. Coloque uma quebra de página onde um trecho deve começar em página ímpar.
- **Cover: 'pages' precisa de um número par de páginas para a capa traseira.** Com binding.cover em 'pages', a primeira página vira como capa da frente, e a última vira como capa traseira só quando cai em uma página par. Com um número ímpar de páginas, o livro fica com capa da frente e sem capa traseira: complete até um número par ou mantenha a capa dura.
- **O visualizador Folio assume o tamanho do seu contêiner.** createFolioFromDocument encaixa o livro no elemento que você passa, com os botões e o contador de páginas nas margens. Um elemento sem altura (um div vazio no fluxo normal) não mostra nada: defina uma altura para ele em CSS.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Crimson Pro (OFL-1.1), Libre Caslon Display (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 103 · Uma bancada para os ajustes do visualizador Folio](https://postext.dev/pt/cookbook/folio-settings-bench.md): Um formulário com cada ajuste de config.folio controla um visualizador postext-folio com setAppearance: papel, encadernação, capas, mesa e luz mudam na hora. · Nível 2 (Intermediário) · Manuais, guias e obras de referência
- [Nº 026 · Espécime com todas as fontes carregadas antes de compor](https://postext.dev/pt/cookbook/fonts-before-layout.md): Um espécime tipográfico de quatro páginas cuja primeira composição nomeia cada fonte usada; o pen as carrega, limpa o cache de larguras e compõe de novo. · Nível 2 (Intermediário) · Catálogos
- [Nº 034 · Verbetes de catálogo diante das suas pranchas](https://postext.dev/pt/cookbook/catalogue-facing-plates.md): Cada verbete abre uma página par e cita a sua prancha na primeira frase do comentário; a prancha flutua para a ímpar em frente, com 221 mm de altura. · Nível 3 (Avançado) · Catálogos
