# Romance em brochura: aberturas rebaixadas em página ímpar

> Quincas Borba em brochura de 140 × 216 mm: capa pintada, colofão sem cabeços e cada capítulo rebaixado numa página ímpar sob um numeral romano em itálico.

- Versão HTML: https://postext.dev/pt/cookbook/trade-paperback-novel
- Receita Nº 038 · Publicações completas · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Ficção, teatro e prosa literária
- Requer postext ≥ 1.4.1 · testada com 1.20.3 em 2026-10-07
- Páginas: [1](https://postext.dev/cookbook/trade-paperback-novel/pt/p01.webp?v=75a7079f), [2](https://postext.dev/cookbook/trade-paperback-novel/pt/p02.webp?v=75a7079f), [3](https://postext.dev/cookbook/trade-paperback-novel/pt/p03.webp?v=75a7079f), [4](https://postext.dev/cookbook/trade-paperback-novel/pt/p04.webp?v=75a7079f), [5](https://postext.dev/cookbook/trade-paperback-novel/pt/p05.webp?v=75a7079f), [6](https://postext.dev/cookbook/trade-paperback-novel/pt/p06.webp?v=75a7079f), [7](https://postext.dev/cookbook/trade-paperback-novel/pt/p07.webp?v=75a7079f), [8](https://postext.dev/cookbook/trade-paperback-novel/pt/p08.webp?v=75a7079f)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=trade-paperback-novel&lang=pt (.postext: https://postext.dev/cookbook/trade-paperback-novel/pt/trade-paperback-novel.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/trade-paperback-novel.md), [es](https://postext.dev/es/cookbook/trade-paperback-novel.md), [ca](https://postext.dev/ca/cookbook/trade-paperback-novel.md), [zh](https://postext.dev/zh/cookbook/trade-paperback-novel.md), [ja](https://postext.dev/ja/cookbook/trade-paperback-novel.md), [ar](https://postext.dev/ar/cookbook/trade-paperback-novel.md)

## Em poucas palavras

Os três primeiros capítulos de um romance brasileiro numa edição em brochura. Cada capítulo começa numa página da direita, mais abaixo, sob um número de capítulo grande.

## O que você vai compor

Oito páginas de uma brochura de 140 × 216 mm trazem os três primeiros capítulos de *Quincas Borba* (1891), de Machado de Assis. A capa, uma pintura gerada com modelos de difusão e carregada como JPEG, mostra o mar entre os troncos de duas árvores: um veleiro no horizonte e uma sombrinha branca subindo da praia entre flores amarelas e brancas. O colofão fica no pé do verso da capa. Cada capítulo abre numa página ímpar com o texto a 81 mm do topo, sob um numeral em itálico de 48 pt, um fio vermelho-ferrugem de 18 mm e a primeira frase do capítulo em versaletes, centralizada e, quando ocupa duas linhas, quebrada pelo sentido. Os capítulos I e II, curtos, terminam nas próprias aberturas, nas páginas 3 e 5, com uma vinheta de camomila; as páginas 4 e 6 ficam em branco, sem cabeço nem fólio, e o capítulo III abre na página 7. Os cabeços trazem o autor nas páginas pares e o título em itálico nas ímpares; nestas oito páginas, só a página 8 leva cabeço.

**Esta receita responde a:**

- Como faço para começar cada capítulo numa página ímpar, a um terço da altura, como num romance em brochura?
- Como acrescento a uma abertura uma linha de autor, uma linha fina ou um primeiro parágrafo com capitular?
- Como insiro páginas em branco de propósito, ou começo uma seção em uma nova página dupla?
- Como configuro os cabeços: título do livro na página esquerda, título do capítulo na direita e fólio do lado de fora?
- Como faço uma capa, uma falsa folha de rosto, uma folha de rosto e um colofão?
- Como acrescento espaço vertical entre dois blocos, se as linhas em branco não fazem nada?
- Como componho ilustrações sem numeração: ornamentos, vinhetas, logotipos?

## A resposta curta

```js
// script.js, linhas 47–79
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
```

## Ingredientes

**Ensina**

- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado): Um título desenhado como composição livre de textos, fios, caixas e imagens, que reserva a altura de que precisa acima do texto.
- [Capítulos que abrem em página ímpar](https://postext.dev/pt/docs/configuration.md#quebra-antes): Um nível de título começa em página nova: a seguinte, a próxima ímpar ou a próxima par, com uma página em branco acrescentada quando necessário.
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título): Um estilo de título traz o próprio cabeçalho e o próprio rodapé, e assim os elementos pré-textuais, as seções ou as letras de um dicionário têm os seus.

**Também usa**

- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Capas, folhas de rosto e colofões](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Geometria por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Metadados do documento](https://postext.dev/pt/docs/document-format.md#frontmatter)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Viúvas, órfãs e linhas curtas](https://postext.dev/pt/docs/configuration.md#órfãs-viúvas-linhas-curtas-e-regras-de-manter-junto)

**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), [`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), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

- Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)

## Preparo

### 1 · A abertura sai do título e do seu atributo

O código é a [resposta curta](https://postext.dev/pt/cookbook/trade-paperback-novel.md#a-resposta-curta) acima. Os capítulos de Machado não têm título, então cada título só traz o numeral (`# I`), que `{titleText}` imprime. `{attr.lead}` imprime o atributo `lead` do título, que guarda a primeira frase do capítulo; o parágrafo Markdown sob o título começa com a frase seguinte ([atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)). Texto de design não pode ser justificado, por isso a primeira frase fica centralizada, e o `\n` a quebra pelo sentido, depois de *enseada,* e depois de *há*; a primeira frase do capítulo III, *Um criado trouxe o café.*, cabe numa linha e não leva `\n`. Esse `\n` só quebra a linha quando `paragraphIndent` é maior que zero; com 0, a frase imprime os dois caracteres. A caixa da primeira frase começa nove linhas da grade abaixo do topo da mancha, e um `minHeight` de 12 linhas deixa uma linha em branco embaixo dela; sem `minHeight`, o texto começa logo abaixo da frase ([span e design avançado](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)). `marginBottom: pt(0)` substitui a margem padrão de meio eme do nível, que empurraria o texto uma linha para baixo.

`breakBefore` com paridade `'odd'` manda cada capítulo para uma página ímpar. A config repete isso porque qualquer objeto `headings` descarta a quebra padrão do nível ([quebrar antes](https://postext.dev/pt/docs/configuration.md#quebra-antes)). Para forçar uma página ímpar onde não há título, escreva `:::pagebreak{parity="odd"}` no Markdown; numa diagramação de duas colunas ou mais, `:::columnbreak` passa o texto para a coluna seguinte ([`:::pagebreak`](https://postext.dev/pt/docs/document-format.md#pagebreak)).

### 2 · Uma página de brochura com linhas inteiras

```js
// script.js, linhas 33–43
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
```

A margem inferior é o que sobra dos 216 mm do refile depois da margem superior e de 35 linhas de 14 pt, então toda página cheia termina na mesma linha, a última da mancha, onde também termina o colofão da página 2. Com `mirror: true`, a margem interna de 20 mm cai do lado da lombada tanto nas ímpares quanto nas pares ([margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)). A medida de 104,5 mm comporta uns 69 caracteres de Crimson Pro de 10,5 pt. O texto corrido mantém os espaços entre palavras entre 0,7 e 1,9 do normal; com o mínimo padrão de 0,6, uma linha destas páginas se fecha até 0,62. Ele também define `maxRuntTracking: 0`. Na edição em inglês, com o valor padrão, o parágrafo que termina em *nodding good-by to him.* é composto com uma linha a menos usando um tracking negativo que o canvas não pinta, e os espaços entre palavras saem esmagados.

### 3 · Cabeços que pulam aberturas e páginas em branco

```js
// script.js, linhas 83–103
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
```

`parity: 'even'` põe o autor em versaletes de Cormorant SC nas páginas pares, e `'odd'` põe o título em itálico nas ímpares; os fólios ficam na borda externa da mancha. `pages: 'body'` deixa os quatro elementos fora das aberturas e das páginas 4 e 6, as páginas pares em branco que a quebra com paridade acrescenta. Com `pages: 'all'`, a página 6 imprimiria *6* e *Machado de Assis*. As aberturas recebem no lugar um fólio no pé, um elemento de rodapé que `pages: 'opener'` restringe a elas ([elementos de texto](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)).

### 4 · A capa é uma seção sem cabeços

```js
// script.js, linhas 107–131
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
```

A capa é um título, `# Quincas Borba {style="cover"}`. O estilo dele põe a pintura nos 134 mm de cima, o título do frontmatter em 50 pt e o autor em versaletes. O estilo também vale para a seção que o título abre, as páginas 1 e 2, até o capítulo I. O `header` e o `footer` vazios tiram o fólio no pé da capa e os cabeços do colofão. A margem superior dele põe o colofão nas seis últimas linhas da grade da mancha, então a última linha do colofão fica na última linha da mancha, a 35ª da grade ([estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)). O estilo precisa de `span: 'page'` mesmo com uma só coluna. Um design preso à coluna é cortado no topo da coluna, aqui a 165 mm; sem `span: 'page'`, a pintura não seria impressa e o título perderia a parte de cima.

![Página 2. O colofão, em corpo pequeno no pé da página, sem cabeço nem fólio: “Publicado em livro por B. L. Garnier, Rio de Janeiro, em 1891.”](https://postext.dev/cookbook/trade-paperback-novel/pt/p02.webp?v=75a7079f)

*Página 2: o colofão no pé do verso da capa, sem cabeço nem fólio.*

### 5 · Uma vinheta fecha cada capítulo

```js
// script.js, linhas 135–155
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
  altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
    + 'white sunshade comes up from the beach through the camomile.',
  pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
    + 'branca sobe da praia por um caminho entre flores amarelas e brancas.' }) };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'] }).map((n, i) => svg(`tailpiece-${i + 1}`,
    'fleuron.svg', 40, 12, t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
      pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.` }))),
];
```

O tipo ornamento não tem prefixo de legenda, então a flor de camomila sai sem rótulo, com 17% da medida de largura, onde está `::resource{id="tailpiece-1"}` ([tipos de recurso](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)). Um elemento incorporado no texto mantém uma linha da grade acima dele, e o `:::space` que vem antes acrescenta uma segunda entre a última linha do capítulo e a flor ([`:::space`](https://postext.dev/pt/docs/document-format.md#space)). As três vinhetas desenham o mesmo arquivo, `fleuron.svg`, sob três ids, porque um recurso só é posicionado uma vez.

## 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/trade-paperback-novel

### script.js

```js
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: The Awakening (Gutenberg #160), Quincas Borba (PD) · Cover: diffusion models
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'pt'; // @lang: the sample's language: 'en' (Chopin) | 'pt' (Machado de Assis)
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
  altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
    + 'white sunshade comes up from the beach through the camomile.',
  pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
    + 'branca sobe da praia por um caminho entre flores amarelas e brancas.' }) };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'] }).map((n, i) => svg(`tailpiece-${i + 1}`,
    'fleuron.svg', 40, 12, t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
      pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.` }))),
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', pt: 'pt' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Quincas Borba"
author: "Machado de Assis"
---

# Quincas Borba {style="cover"}

:::paragraphs{style="colophon"}
Publicado em livro por B. L. Garnier, Rio de Janeiro, em 1891. Os capítulos I a III seguem a primeira edição, transcrita no Wikisource, com a ortografia atualizada.

Composto em Crimson Pro, Cormorant Garamond e Cormorant SC (SIL Open Font License). A capa, uma pintura, mostra uma praia vista entre as árvores.

Uma edição das Receitas do Postext. O texto está em domínio público.
:::

# I {lead="Rubião fitava a enseada,\n— eram oito horas da manhã."}

Quem o visse, com os polegares metidos no cordão do chambre, à janela de uma grande casa de Botafogo, cuidaria que ele admirava aquele pedaço de água quieta; mas, em verdade, vos digo que pensava em outra coisa. Cotejava o passado com o presente. Que era, há um ano? Professor. Que é agora? Capitalista. Olha para si, para as chinelas (umas chinelas de Túnis, que lhe deu recente amigo, Cristiano Palha), para a casa, para o jardim, para a enseada, para os morros e para o céu; e tudo, desde as chinelas até o céu, tudo entra na mesma sensação de propriedade.

— Vejam como Deus escreve direito por linhas tortas, pensa ele. Se a mana Piedade tem casado com o Quincas Borba, apenas me daria uma esperança colateral. Não casou; ambos morreram, e aqui está tudo comigo; de modo que o que parecia uma desgraça...

:::space

::resource{id="tailpiece-1"}

# II {lead="Que abismo que há\nentre o espírito e o coração!"}

O espírito do ex-professor, vexado daquele pensamento, arrepiou caminho, buscou outro assunto, uma canoa que ia passando; o coração, porém, deixou-se estar a bater de alegria. Que lhe importa a canoa nem o canoeiro, que os olhos de Rubião acompanham, arregalados? Ele, coração, vai dizendo que, uma vez que a mana Piedade tinha de morrer, foi bom que não casasse; podia vir um filho ou uma filha... — Bonita canoa! — Antes assim! — Como obedece bem aos remos do homem!

— O certo é que eles estão no céu!

:::space

::resource{id="tailpiece-2"}

# III {lead="Um criado trouxe o café."}

Rubião pegou na xícara e, enquanto lhe deitava açúcar, ia disfarçadamente mirando a bandeja, que era de prata lavrada. Prata, ouro, eram os metais que amava de coração; não gostava de bronze, mas o amigo Palha disse-lhe que era matéria de preço, e assim se explica este par de figuras que aqui está na sala, um Mefistófeles e um Fausto. Tivesse, porém, de escolher, escolheria a bandeja, — primor de argentaria, execução fina e acabada. O criado esperava teso e sério. Era espanhol; e não foi sem resistência que Rubião o aceitou das mãos de Cristiano; por mais que lhe dissesse que estava acostumado aos seus crioulos de Minas, e não queria línguas estrangeiras em casa, o amigo Palha insistiu, demonstrando-lhe a necessidade de ter criados brancos. Rubião cedeu com pena. O seu bom pajem, que ele queria pôr na sala, como um pedaço da província, nem o pôde deixar na cozinha, onde reinava um francês, Jean; foi degradado a outros serviços.

— Quincas Borba está muito impaciente? perguntou Rubião bebendo o último gole de café, e lançando um último olhar à bandeja.

— Me parece que sí.

— Lá vou soltá-lo.

Não foi; deixou-se ficar, algum tempo, a olhar para os móveis. Vendo as pequenas gravuras inglesas, que pendiam da parede por cima dos dois bronzes, Rubião pensou na bela Sofia, mulher do Palha, deu alguns passos, e foi sentar-se no pouf, ao centro da sala, olhando para longe...

— Foi ela que me recomendou aqueles dois quadrinhos, quando andávamos, os três, a ver coisas para comprar. Estava tão bonita! Mas o que eu mais gosto dela são os ombros, que vi no baile do coronel. Que ombros! Parecem de cera; tão lisos, tão brancos! Os braços também; oh! os braços! Que bem feitos!

Rubião suspirou, cruzou as pernas, e bateu com as borlas do chambre sobre os joelhos. Sentia que não era inteiramente feliz; mas sentia também que não estava longe a felicidade completa. Recompunha de cabeça uns modos, uns olhos, uns requebros sem explicação, a não ser esta, que ela o amava, e que o amava muito. Não era velho; ia fazer quarenta e um anos; e, rigorosamente, parecia menos. Esta observação foi acompanhada de um gesto; passou a mão pelo queixo, barbeado todos os dias, coisa que não fazia dantes, por economia e desnecessidade. Um simples professor! Usava suíças, (mais tarde deixou crescer a barba toda), — tão macias, que dava gosto passar os dedos por elas... E recordava assim o primeiro encontro, na estação de Vassouras, onde Sofia e o marido entraram no trem da estrada de ferro, no mesmo carro em que ele descia de Minas; foi ali que achou aquele par de olhos viçosos, que pareciam repetir a exortação do profeta: Todos vós que tendes sede, vinde às águas. Não trazia ideias adequadas ao convite, é verdade; vinha com a herança na cabeça, o testamento, o inventário, coisas que é preciso explicar primeiro, a fim de entender o presente e o futuro. Deixemos Rubião na sala de Botafogo, batendo com as borlas do chambre nos joelhos, e cuidando na bela Sofia. Vem comigo, leitor; vamos vê-lo, meses antes, à cabeceira do Quincas Borba.

:::space

::resource{id="tailpiece-3"}
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the fleuron, drawn in millimetres in the page's colour
function drawFleuron(color) { // a camomile head between two leafy sprigs
  const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" `
    + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join('');
  const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" `
    + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>`
    + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>`
    + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>`
    + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" '
    + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}`
    + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Crimson Pro': ['400', '400i'], // text, colophon, folios
  'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head
  'Cormorant SC': ['600'], // the leads, the author on the cover and the verso
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadImage(painting.bitmap.fileId, asset(painting.bitmap.fileId));
await loadSvg('fleuron.svg', drawFleuron(palette.rubric));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Trade paperback: sunk openers and recto chapters',
  pt: 'Romance em brochura: aberturas rebaixadas em página ímpar' }) });

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

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

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

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

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

## Variações

### Deixar o capítulo II seguir na página seguinte

Com paridade `'any'`, o capítulo II abre na página 4, uma página par, o III na página 5, e o livro termina na página 6; o fólio no pé é centralizado na mancha, não na página, então nessa página par ele continua sob o meio do texto.

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
```

### Marcar uma quebra de cena dentro de um capítulo

Entre duas linhas `:::space`, a mesma flor marca uma quebra de cena, e o parágrafo seguinte começa sem recuo porque `indentAfterHeading` está desligado. Na edição em inglês, posta depois de *seemed long to him.*, a flor cai no pé da página 4; o segundo espaço já não cabe ali e é descartado. A quebra acrescenta quatro linhas, que empurram a vinheta do capítulo I sozinha para a página 6, sob um cabeço, então ajuste o texto do capítulo de novo depois de acrescentar uma.

```diff
 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
```

```md
That was why the morning seemed long to him.

:::space

::resource{id="break-1"}

:::space

“You are burnt beyond recognition,” he added, …
```

## Erros comuns

- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **A página 1 é ímpar: planeje com os números físicos.** A página 1 fica à direita e a 2 é a primeira página par, então planeje as páginas duplas com os números físicos de página: uma abertura em página par fica de frente para a ímpar que vem depois dela.
- **\n em um atributo só quebra linha com paragraphIndent > 0.** Em um elemento de texto de design, um \n escrito no valor de um atributo só abre uma linha nova quando paragraphIndent é maior que zero ou há capitular; caso contrário, o texto fica em uma linha só. Dê a paragraphIndent um valor mínimo (0,01 pt) ou use um atributo por linha.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **Texto de design nunca é justificado, então o texto ao lado da capitular fica em bandeira.** No postext 1.4.1, um elemento de texto de design se alinha à esquerda, ao centro ou à direita e quebra palavra por palavra: não há alinhamento justificado, e hyphenate: true só divide uma palavra longa demais para uma linha inteira. As linhas de um texto inicial composto ao lado de uma dropCap terminam, portanto, em bandeira junto ao texto corrido justificado. Limite o texto inicial às linhas ao lado da capitular e ajuste-as à mão: com lines: 1 (uma capitular elevada) o texto inicial é uma linha só, que o gap da dropCap consegue deixar rente; o parágrafo continua no Markdown.
- **O excesso de texto de design é 'ellipsis-end' por padrão.** Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas.
- **O ajuste de linhas curtas pode apertar um tracking que nunca é pintado.** No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **::resource{id="…"} só aceita aspas duplas.** Uma inserção em bloco só é reconhecida como ::resource{id="…"} com aspas duplas; qualquer outra forma fica no texto como uma linha visível.
- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **Coloque entre aspas cada valor do frontmatter.** O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984".
- **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.
- **Aviso de diagramação: Cascata de paridade** (`parityCascade`). Quebras com paridade se acumulam e produzem mais de duas páginas em branco seguidas. Solução: Revise as quebras always-odd e as paridades de breakBefore; em capítulos curtos, a paridade any costuma evitar essas páginas em branco. ([Documentação](https://postext.dev/pt/docs/configuration.md#quebra-antes))

- Com `'always-odd'`, uma página separadora em branco vem depois de cada seção, antes de qualquer preenchimento de paridade, então, na edição em inglês, o capítulo I passaria para a página 5, depois de duas páginas em branco, e o livro chegaria a dez páginas.
- As páginas da edição em inglês foram ajustadas linha a linha. Com uma margem externa de 16 mm em vez de 15,5, a página 7 parte *after-din-ner* num segundo hífen e termina uma linha antes, a página 8 fecha um parágrafo com o fragmento *spondent.*, e a segunda vinheta cai numa nona página. Confira o pé de todas as páginas depois de mudar a medida, o corpo ou o rebaixamento.
- Na edição em inglês, *Mr.* e *Mrs.* ficam presos ao nome por um espaço não separável (U+00A0). Na 1.4.1 ele se mantém em parágrafos simples, mas num parágrafo com itálico quebra como um espaço comum. O parágrafo que termina em *seemed long to him.* fica com um espaço comum, porque prender o nome ali abre a primeira linha dele até 2,2 vezes o espaço normal entre palavras.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: The Awakening (O despertar, 1899), capítulos I e II, na edição em inglês: Kate Chopin ([fonte](https://www.gutenberg.org/ebooks/160)), domínio público
- Texto: Quincas Borba (1891), capítulos I a III, com a ortografia atualizada: Machado de Assis ([fonte](https://pt.wikisource.org/wiki/Quincas_Borba)), domínio público
- Texto: O colofão: Postext Cookbook, original
- Imagens: A vista do mar da capa, uma pintura: Generated With Diffusion Models, original
- Imagens: A vinheta de camomila, desenhada em código com a paleta da página: Ignacio Ferro, MIT
- Tipos: Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: MIT

## Relacionadas

- [Nº 016 · Espanhol justificado num romance de bolso](https://postext.dev/pt/cookbook/spanish-pocket-novel.md): O início de Marianela em edição de bolso: hifenização espanhola, espaços abaixo de 1,7×, sem linhas curtas, capitular elevada e o mapa como Figura 1. · Nível 2 (Intermediário) · Ficção, teatro e prosa literária
- [Nº 017 · Cinco aberturas de capítulo num só livro](https://postext.dev/pt/cookbook/five-chapter-openers.md): Uma abertura no nível 1 e quatro estilos de título nomeados no Markdown; cada estilo troca a cor de destaque, e alguns mudam também margens, colunas ou fólios. · Nível 3 (Avançado) · Qualquer gênero
- [Nº 006 · Pré-textuais com fólios romanos, depois a página 1](https://postext.dev/pt/cookbook/front-matter-roman-to-arabic.md): A capa e os pré-textuais são títulos sem número contados em romanos minúsculos; :::numbering reinicia a contagem em 1 na página ímpar onde o romance começa. · Nível 3 (Avançado) · Ficção, teatro e prosa literária
