# Títulos de seção em sete níveis

> Seções numeradas de 1.1 a 1.12 numa pílula âmbar que cresce com o número, mais quatro níveis abaixo delas e um sétimo feito com um estilo de título.

- Versão HTML: https://postext.dev/pt/cookbook/section-heads-field-manual
- Receita Nº 018 · Títulos e aberturas · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Manuais, guias e obras de referência
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/section-heads-field-manual/en/p01.webp?v=10432282), [2](https://postext.dev/cookbook/section-heads-field-manual/en/p02.webp?v=10432282), [3](https://postext.dev/cookbook/section-heads-field-manual/en/p03.webp?v=10432282)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=section-heads-field-manual&lang=en (.postext: https://postext.dev/cookbook/section-heads-field-manual/en/section-heads-field-manual.postext)
- Última atualização: 2026-09-25
- Outros idiomas: [en](https://postext.dev/en/cookbook/section-heads-field-manual.md), [es](https://postext.dev/es/cookbook/section-heads-field-manual.md), [ca](https://postext.dev/ca/cookbook/section-heads-field-manual.md), [zh](https://postext.dev/zh/cookbook/section-heads-field-manual.md), [ja](https://postext.dev/ja/cookbook/section-heads-field-manual.md), [ar](https://postext.dev/ar/cookbook/section-heads-field-manual.md)

## Em poucas palavras

Um capítulo de um manual de campo para equipes que cuidam de trilhas, sobre como tirar a água dos caminhos. Mostra como dar sete níveis aos títulos, cada um com a sua cara, para o leitor ver como as partes se encaixam.

## O que você vai compor

O capítulo 1 do manual de campo de uma equipe de trilhas, *Drainage*, em três páginas B5 a duas colunas: IBM Plex Serif no texto, IBM Plex Sans Condensed nos títulos e nos números de seção, IBM Plex Mono nos rótulos, nos fólios e nos números de subseção. A abertura põe o número do capítulo numa pílula âmbar grande sobre o perfil altimétrico de uma trilha, onde pontos âmbar marcam doze locais escolhidos para novos desviadores de água. Pílulas menores numeram as seções 1.1 a 1.12 e ficam mais largas no 1.10. Os títulos 1.2.1, 1.5.1 e 1.5.2 vão em maiúsculas espaçadas sob um fio verde. Os níveis 4 a 6 não têm número e mudam de letra (itálico serifado, condensado negrito em verde, maiúsculas monoespaçadas), e um sétimo nível em itálico cinza nomeia as ferramentas. As regras de segurança começam com termos corridos em verde. Um checklist sem número, marcado por um quadrado vazado, fecha o capítulo com listas numeradas 1., a) e i. e duas caixas de tarefa.

**Esta receita responde a:**

- Como faço para numerar títulos como 1.1 e 1.1.1, dar um estilo a cada nível e pôr o número numa pílula?
- Como faço um selo com o número ao lado do título que se alarga quando o número cresce (9 → 10)?
- O que faço se preciso de mais de seis níveis de título?
- Como personalizo listas: marcadores por nível, numeração (a)/(i), caixas de seleção de tarefas e um espaçamento que fique na grade?

## A resposta curta

```js
// script.js, linhas 34–51
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
```

## Ingredientes

**Ensina**

- [Títulos numerados](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível): Modelos de numeração por nível (1, 1.1, IV, A, 01), que as aberturas, os cabeços e o sumário também imprimem.
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés): Os elementos de desenho comuns a cabeçalhos, rodapés, aberturas e páginas de parte: texto com pílula opcional, fios horizontais e verticais e caixas preenchidas ou com contorno, pintados na ordem da lista.
- [Ancoragem de elementos de design](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos): Posiciona os elementos em relação ao contêiner, à página, à sangria ou a outro elemento (right-of, below, align-*) em vez de usar coordenadas.

**Também usa**

- [Níveis de título](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível)
- [Estilos de título](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)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [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)
- [Negrito, itálico e suas cores](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Listas com marcadores e de verificação](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)
- [Listas numeradas](https://postext.dev/pt/docs/configuration.md#listas-numeradas)
- [Grade de linhas de base](https://postext.dev/pt/docs/configuration.md#grade-de-linhas-de-base)
- [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)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)

**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), [`orderedLists`](https://postext.dev/pt/docs/configuration.md#listas-numeradas), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`unorderedLists`](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)

**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**

- IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)

## Preparo

### 1 · Ponha o número numa pílula que cresce com ele

O código está [na resposta curta](https://postext.dev/pt/cookbook/section-heads-field-manual.md#a-resposta-curta) lá em cima. `numberingTemplate: '{1}.{2}'` junta os contadores do capítulo e da seção em 1.1 a 1.12 ([ajustes por nível](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível)). Um nível com design avançado deixa de imprimir o número antes do título, então o próprio design posiciona `{number}`. Aqui ele fica num elemento de texto com uma `box` preenchida, de cantos arredondados e sem largura, de modo que a pílula tem a largura do número mais o preenchimento: 10,1 mm no 1.9 e 12,7 mm no 1.10. `'right-of'` pendura o título na borda direita da pílula e alinha as suas linhas à esquerda, e é por isso que o título longo da 1.5 quebra ao lado do número ([posicionamento de elementos](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos)). O título também precisa de `overflow: 'wrap'`, porque por padrão o texto de design que não cabe é cortado com reticências. A pílula tem 3 pt a menos que duas linhas da grade, e todo título de seção começa numa linha da grade, então esses 3 pt são o espaço entre a pílula e o texto embaixo dela, quer o título abra uma coluna, quer venha depois de um parágrafo.

![Page 3: 1.8 Lead-off ditches.](https://postext.dev/cookbook/section-heads-field-manual/en/p03.webp?v=10432282)

*Página 3: a pílula se alarga do 1.9 para o 1.10, e o título anda para a direita a largura do algarismo a mais.*

### 2 · Dê fio e espaçamento ao terceiro nível

```js
// script.js, linhas 55–67
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
```

Na versão 1.4.1, um nível de título não tem `letterSpacing`, mas o texto de design tem, então o nível 3 também é desenhado por um design: um fio verde de 0,75 pt, o número em IBM Plex Mono e, ao lado, o título espaçado. O terceiro contador de `'{1}.{2}.{3}'` recomeça em cada seção, por isso 1.2.1 e 1.5.1 terminam os dois em 1. `DROP` baixa só o fio. O número fica `LEAD - DROP` abaixo dele, o que mantém número e título uma linha da grade abaixo do alto do título, qualquer que seja o `DROP`. Com o fio mais perto do número do que do parágrafo de cima, ele é lido como parte do título.

### 3 · Desça pelos níveis 4 a 6

```js
// script.js, linhas 96–107
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
```

Um nível sem `numberingTemplate` não imprime número, então do nível 4 para baixo os títulos se distinguem pela letra, pela cor ou pela caixa: um itálico serifado no nível 4, a família de títulos em verde no 5 e maiúsculas monoespaçadas em seminegrito no 6. A entrelinha e as margens definidas no próprio `headings` chegam a todos os níveis e põem cada título na grade, com uma linha em branco acima e nenhuma abaixo. Qualquer objeto `headings` desliga a quebra de página padrão antes de um capítulo, por isso o nível 1 a declara de novo.

### 4 · Faça um sétimo nível e uma seção sem número com estilos

```js
// script.js, linhas 111–130
const headingStyles = [
  // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
  // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
```

O Markdown para em `######`, e um título perde as marcas de negrito e itálico, então `###### *Rock bar*` sairia como mais um nível 6. O estilo de título `'level7'` compõe os nomes das ferramentas num itálico condensado cinza de peso 500, mais leve que o 600 do nível 6, e `textTransform: 'none'` desliga as maiúsculas que eles herdariam. Eles são lidos um degrau abaixo do rótulo monoespaçado acima deles, embora, com 8,4 pt, sejam maiores que os 7,8 pt do rótulo ([estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)). `numbered: false` deixa o checklist fora da contagem, então uma seção depois dele continuaria sendo a 1.13. Um `{number}` vazio ainda pintaria a pílula âmbar, por isso o estilo traz o seu próprio design, com um quadrado vazado no lugar da pílula. Os termos corridos em verde das regras de segurança vêm do `boldColor` de um estilo de parágrafo.

### 5 · Mude os marcadores da lista com a profundidade

```js
// script.js, linhas 134–141
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
```

Cada profundidade recebe de `levels` o seu marcador, a sua cor e o seu separador: o checklist conta 1., a) e i. ([ajustes por nível das listas ordenadas](https://postext.dev/pt/docs/configuration.md#substituições-por-nível-listas-numeradas)) e os marcadores passam do verde ao verde-sálvia ([ajustes por nível das listas não ordenadas](https://postext.dev/pt/docs/configuration.md#substituições-por-nível-listas-com-marcadores)). O nível 1 mantém o formato padrão, `'arabic'`; `'decimal'`, a palavra que o CSS usa, imprimiria “undefined”. Os dois itens `- [ ]` que fecham o checklist imprimem `taskCheckboxChar`, ☐ por padrão, no lugar do marcador, no verde dos marcadores de primeiro nível ([extensões de listas de tarefas](https://postext.dev/pt/docs/configuration.md#extensões-para-listas-de-tarefas)). Com margens zero acima e abaixo, todas as listas ficam na grade de linhas de base.

### 6 · Abra o capítulo sobre o perfil da trilha

```js
// script.js, linhas 71–92
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
```

O perfil é um elemento de imagem da abertura, ancorado na página e tão largo quanto ela. Uma figura da largura da página, flutuando para o alto e citada na página 1, teria aberto a página 2 ([elementos de imagem](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)). Numa abertura, uma imagem não reserva altura, então `minHeight` começa o texto na primeira linha da grade que fique 6 mm ou mais abaixo dela. A legenda sobre o verde é texto de design, porque um SVG desenhado como imagem não consegue usar as fontes da página. A pílula grande reaproveita a letra e o preenchimento da pílula de seção, e `{number}` imprime o número do próprio capítulo, vindo de `numberingTemplate: '{1}'`.

## 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/section-heads-field-manual

### script.js

```js
// ═══ Postext Cookbook · Nº 018 · Section heads seven levels deep ═════════════════
// https://postext.dev/en/cookbook/section-heads-field-manual
// Code: MIT · Text: original (CC BY 4.0) · Picture: drawn in code (MIT)
// Fonts: IBM Plex Serif, Sans Condensed, Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'section-heads-field-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // forest green for structure, a signal amber for numbers
  ink: '#1d2320', // text: a green-black
  band: '#2f6b3f', // the accent: rules, run-in terms, bullets, numbers, folios (6.4:1)
  signal: '#e0a526', // the number pills, with ink on them (7.3:1)
  sage: '#7a9e80', // the second bullet and the profile's upper contours
  tint: '#e9f0e6', // the opener's sky; the legend on the green (5.5:1)
  muted: '#5f6a62', // running heads, level 7, roman list numbers, the colophon (5.6:1)
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM = { width: 176, height: 250 }; // mm: ISO B5, a common size for field manuals
const [TOP, INNER, OUTER] = [22, 16, 14]; // mm: margins; the running heads align to OUTER
const LEAD = 13.2; // pt: the body leading, the pitch of the baseline grid
const LINES = 44; // grid lines in the text block, so every full column ends on one baseline
const [DISPLAY, LABEL] = ['IBM Plex Sans Condensed', 'IBM Plex Mono']; // with the serif text
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x, y } });

// #region answer: section numbers 1.1 … 1.12 in an amber pill that widens with the number
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
// #endregion

// #region ruled: level 3, a green rule over the number and a tracked capital title
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
// #endregion

// #region opener: the chapter number in the section pill, scaled up, over the trail's profile
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
// #endregion

// #region levels: numbers down to 1.1.1, then italic, bold and label faces for 4 to 6
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
// #endregion

// #region styles: a seventh level and an unnumbered section as heading styles; run-in terms
const headingStyles = [
  // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
  // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

// #region lists: bullets that fade with depth; numbers 1. then a) then i.; task boxes
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
// #endregion

// Running heads, HEAD mm from the trim: folio and book on versos, chapter and folio on rectos.
const HEAD = 12; // mm; an opener keeps only a drop folio, HEAD mm above its foot
const FOLIO_GAP = 9; // mm from a folio to the title beside it
const runHead = { fontFamily: DISPLAY, fontWeight: 600, fontSize: pt(7.8), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted') };
const folio = { ...runHead, fontFamily: LABEL, color: col('band') };
const head = (id, content, parity, edge, x, style = runHead) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...style, placement: at('page', edge, mm(x), mm(HEAD)) });

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'IBM Plex Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.85, maxWordSpacing: 1.4, // a narrow band: an even grey, line to line
    maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted)
  headings, headingStyles, paragraphStyles, unorderedLists, orderedLists,
  header: { elements: [head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('v-book', '{title}', 'even', 'top-left', OUTER + FOLIO_GAP),
    head('r-chapter', t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
      es: 'Capítulo {chapterNumber} · {chapterTitle}' }), 'odd', 'top-right', -(OUTER + FOLIO_GAP)),
    head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...folio, placement: at('page', 'bottom', mm(0), mm(-HEAD)) }] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Trail Crew Field Manual"
subtitle: "Maintenance with hand tools"
author: "Postext Cookbook"
---

# Drainage {lead="Where and how to build the drains of a trail, from an outsloped tread to a stone culvert." profile="Lookout Ridge Trail, km 0 to 4.2 · twelve sites flagged for new water bars"}

In one season, boots pack a new trail until its tread sheds rain like a metal roof, and the rain runs down it, picking up speed and soil. This chapter shows how to turn that water off the trail before it cuts a rut.

## Why water is the enemy

The faster water runs, the more soil it carries away, and it runs faster the steeper the grade and the longer the run. A sheet of water that barely moves on a flat tread turns into a cutting stream on a long, steep pitch. Once a rut forms, hikers walk beside it, the tread widens and each storm digs the rut deeper. Drainage breaks the run into short pieces, so the water never gets going.

## Reading the ground

Walk the section during a storm if you can, or straight after one. Water will show you where it wants to go. Look for these signs:

- silt fans below a steep pitch
- puddles that hikers step around, wearing a new path beside them
- a rut down the middle of the tread
  - shallower than a boot sole: reshape it
  - deeper: it needs a water bar
- roots and rocks standing proud of the tread

### Flag before you dig

Mark every site with flagging tape before the crew arrives, and record its station in the log: its distance from the trailhead, the grade and the structure you propose. When the section is walked and flagged, a crew leader can plan the day in minutes.

## Outslope first

The cheapest drain is a tread that tilts. Shape it to fall by about 5 per cent toward the downhill edge, 3 cm across a tread 60 cm wide, so that water crosses it in a thin sheet instead of running down its length. Rake off the berm of loose soil that builds up along the outer edge, since a berm turns the tread back into a gutter. Check the tilt with a short level across the tread; an outslope too slight to see still sheds water, and a steeper one only turns ankles.

## Grade dips

In a grade dip, the grade reverses for a short way: the trail drops, rises again for a few metres, then resumes its climb, and water leaves at the low point. Built into new trail, dips are almost invisible to hikers and need little upkeep. On an old trail you can often carve one with a grub hoe where the grade eases.

## Water bars: turning water off steep tread

Where the grade is too steep for a dip, a water bar turns the flow across the tread. It is a line of rock or timber set into the tread at an angle, its top a little above the surface, with an armoured outlet at its lower end.

### Laying out a bar

Skew the bar 30 to 45 degrees off the square, so the water keeps enough speed to carry its silt away; a bar laid straight across the tread fills with sediment after the first storm. Space the bars more closely as the grade steepens: on loose soil at 10 per cent, one every 25 or 30 metres, and closer still on a steeper pitch.

#### Choosing the spot

Place the bar where the water can leave with ease, in a natural hollow on the downhill side. Never let it drain onto a switchback or over a steep drop, where the outflow would cut into the slope below.

### Building a rock bar

Dig a trench across the tread at the angle you chose, two thirds as deep as your tallest rock is high. Set the rocks on edge and shoulder to shoulder, with at least two thirds of each one buried, and key the upper end 30 cm into the bank so that water cannot run around it.

#### The trench

Keep the trench walls vertical and its floor on firm mineral soil. Throw the spoil well downhill, clear of the tread, and keep the best for backfill. On loose soil, widen the trench and line its downhill side with smaller stones, so that the bar rests on something firm.

##### Tools for the trench

A grub hoe and a shovel open it, and two steel bars set the rocks.

###### Steel bars

Each is about 1.5 m long; the heavier weighs as much as a loaded daypack. Lay them down when not in use, never upright against a tree.

###### Rock bar {style="level7"}

The heavy one: a lever to pry rocks loose and walk them into place. Keep your fingers clear of the pivot rock and lift with your legs.

###### Tamping bar {style="level7"}

The lighter bar, with a flat tamping foot at one end. Backfill in layers no thicker than a hand and tamp each one hard: the first flow carries off loose fill behind a bar.

## Knicks

On flat or rolling tread where puddles gather, a knick drains water with no structure at all. It is a shallow half-moon about 3 metres long, shaved into the tread so its outer edge sits a hand’s depth below the rest.

## Check steps

Where the trail climbs a gully and the water cannot be turned aside, slow it down instead. Check steps are low risers of stone or timber set across the tread, each one holding back a level bed of soil, and the water loses speed at every landing. A rise of 15 to 20 cm makes an easy step with a pack on. Key every step well into the banks.

## Lead-off ditches

Water turned off the trail must go somewhere else. A lead-off ditch carries it from a bar or a dip to ground where it can spread out harmlessly. Dig it at least as wide as the outlet, give it an even fall, and end it where the plants are thick enough to catch the silt.

## Culverts

Where a spring or a small stream crosses the trail, carry its water under the tread. An open culvert, two lines of flat rocks with a gap between them, is easy to clean. A culvert roofed with stone slabs makes a smoother tread but needs its inlet cleared after every storm.

## Armouring outlets

Wherever water leaves the trail, it can start a gully of its own. Line the outlet of every bar, dip and culvert with a fan of stones the size of a fist, set into the soil, and carry the armour on until the flow meets plants or bedrock.

## Tool safety

The crew leader checks every tool at the trailhead, and these four rules hold all day:

:::paragraphs{style="rules"}
**Carry.** Edged tools travel by your side, blade down and in its guard, on the downhill side of the trail, and never on a shoulder.

**Spacing.** Keep two tool lengths between workers, and call out before every swing.

**Rock work.** Move rocks with a bar and gravity, not with your back. Nobody stands downhill of a rock that is moving.

**Protection.** A hard hat, gloves, eye protection and stiff-soled boots for the whole crew.
:::

## Recording your work

Log each structure you build or clean, with its station, type, material and condition. After a season, the log shows which drains fail first: redesign those rather than repair them.

## Checklist {style="checklist"}

After every big storm, walk the section with a hoe and a rock bar and work through this list:

1. Water bars
  1. Clear sediment from the channel.
  2. Check the outlet armour.
    1. Reset stones that have moved.
    2. Extend it to where plants begin.
2. Dips and knicks
  1. Restore the outslope.
  2. Clear the lead-off ditches.
3. Culverts
  1. Clear the inlet and the outlet.
  2. Rebuild any headwall that has settled.

- [ ] Flag any damage too big to fix today.
- [ ] Log every repair.

:::paragraphs{style="colophon"}
Text: CC BY 4.0, written for the Postext Cookbook · Set in IBM Plex Serif, IBM Plex Sans Condensed and IBM Plex Mono (SIL OFL)
:::
`; // content.<lang>.md, inlined by the Cookbook
// The profile is a resource that no :ref cites: only the opener's image element draws it.
const resources = [{ id: 'profile', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'profile.svg', width: TRIM.width * 10, height: DEPTH * 10 },
  altText: t({ en: 'A 376 m climb in 4.2 km; amber dots mark twelve sites flagged for water bars.',
    es: 'Subida de 376 m en 4,2 km; puntos ámbar en doce sitios balizados para desviadores.' }) }];

// #region art: the trail's elevation profile, drawn in code with a seeded PRNG
function profileSvg() {
  // Survey points, distance (km) and elevation (m): an easy valley, then the climb.
  const KM = 4.2;
  const pts = [[0, 1180], [0.8, 1190], [1.5, 1204], [2.1, 1226], [2.6, 1262], [3.0, 1330],
    [3.35, 1412], [3.7, 1486], [4.0, 1535], [4.2, 1556]];
  const Y0 = DEPTH - 12; // mm: where 1180 m sits in the picture
  const K = 50 / 376; // mm of picture per metre of climb
  const elev = (d) => { // smoothstep between survey points: monotone, no overshoot
    const next = pts.findIndex(([x]) => x > d);
    const i = next < 0 ? pts.length - 2 : Math.max(0, next - 1);
    const [[x0, e0], [x1, e1]] = [pts[i], pts[i + 1]];
    const u = Math.min(1, (d - x0) / (x1 - x0));
    return e0 + (e1 - e0) * u * u * (3 - 2 * u);
  };
  let seed = 18; // Mulberry32: the same wobble on every run
  const rand = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
  const N = 220;
  const crest = Array.from({ length: N + 1 }, (_, i) => [(TRIM.width * i) / N,
    Y0 - (elev((KM * i) / N) - 1180) * K + (rand() - 0.5) * 0.5]);
  const xy = (list) => list.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L');
  // Contour bands every 50 m, from the band green in the valley to sage on the ridge: each
  // band is the profile clipped between two contours.
  const mix = (a, b, u) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
    * (1 - u) + parseInt(b.slice(i, i + 2), 16) * u).toString(16).padStart(2, '0')).join('');
  const bands = Array.from({ length: 8 }, (_, k) => {
    const floor = Y0 - k * 50 * K;
    const top = crest.map(([x, y]) => [x, Math.min(floor, Math.max(y, floor - 50 * K))]);
    return `<path d="M0 ${floor}L${xy(top)}L${TRIM.width} ${floor}Z" `
      + `fill="${mix(palette.band, palette.sage, k / 7)}"/>`;
  }).join('');
  // The twelve flagged sites, placed one per 32 m of climb: they crowd where it steepens.
  const dots = Array.from({ length: 12 }, (_, k) => {
    const target = 1180 + 32 * (k + 0.5);
    let [lo, hi] = [0, KM];
    for (let it = 0; it < 40; it++) {
      const mid = (lo + hi) / 2;
      if (elev(mid) < target) lo = mid; else hi = mid;
    }
    return `<circle cx="${((TRIM.width * lo) / KM).toFixed(2)}" `
      + `cy="${(Y0 - (target - 1180) * K).toFixed(2)}" r="1.9" fill="${palette.signal}" `
      + `stroke="${palette.ink}" stroke-width="0.35"/>`;
  }).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM.width * 10}" `
    + `height="${DEPTH * 10}" viewBox="0 0 ${TRIM.width} ${DEPTH}">`
    + `<rect width="${TRIM.width}" height="${DEPTH}" fill="${palette.tint}"/>`
    + `<path d="M0 ${DEPTH}L${xy(crest)}L${TRIM.width} ${DEPTH}Z" fill="${palette.band}"/>`
    + `${bands}<path d="M${xy(crest)}" fill="none" stroke="${palette.ink}" `
    + `stroke-width="0.7" stroke-linejoin="round"/>${dots}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'IBM Plex Serif': ['400', '400i', '700'],
  'IBM Plex Sans Condensed': ['500i', '600', '700'], 'IBM Plex Mono': ['400', '500', '600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('profile.svg', profileSvg());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Section heads seven levels deep',
  es: 'Títulos de sección hasta siete niveles' }) });

// ─── 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

### Tire o número do capítulo das pílulas

Tire o contador do capítulo do modelo e as pílulas vão de 1 a 12, alargando no 10; o nível 3 continua imprimindo 1.5.1 até que o seu próprio modelo também tire o `{1}`.

```diff
-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',
```

### Dê a todas as pílulas a mesma largura

Uma largura fixa, a do 1.10, centraliza cada número numa pílula igual e alinha os títulos numa só coluna.

```diff
-  placement: at('container', 'top-left') }; // plus its padding
+  placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };
```

## 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.
- **Um título perde as marcas de negrito e itálico.** No postext 1.4.1, uma linha de título perde as marcas em linha: ###### *Pé de cabra* imprime Pé de cabra na fonte normal do nível 6, sem os asteriscos e sem itálico. Um sétimo nível, ou uma palavra destacada dentro de um título, precisa de um estilo de título ({style="…"}) ou de um design avançado.
- **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.
- **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.
- **As imagens de uma abertura nunca contam para a altura que ela reserva.** No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar.
- **Um flutuante 'top' nunca cai na página que o cita.** Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **Listas usam 'arabic', recursos 'roman-upper', páginas 'upper-roman'.** Cada configuração de numeração escreve os formatos de um jeito: as listas usam numberFormat 'arabic' ('decimal' imprime “undefined”), os tipos de recurso usam counterFormat 'roman-upper', e as páginas e :::numbering usam 'upper-roman'.
- **A maioria dos avisos só existe no Sandbox.** Ids, estilos e diretivas desconhecidos, fontes ausentes e linhas frouxas são verificados pelo Sandbox, não pelo motor: um pen recebe apenas doc.warnings e parseMarkdownWithIssues. Um estilo desconhecido é substituído sem aviso e uma diretiva desconhecida é impressa como texto, então confira os seus ids.
- **Um espaço não separável ainda quebra a linha.** No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase.
- **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".
- **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.
- **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.
- **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.
- **Aviso de diagramação: Salto na hierarquia de títulos** (`headingHierarchy`). Um título pula um nível, por exemplo um H1 seguido diretamente de um H3. Solução: Use o nível imediatamente abaixo ou mude o estilo do nível que você queria, em vez de pular um. ([Documentação](https://postext.dev/pt/docs/configuration.md#avisos))

- O sétimo nível é um título de nível 6 com o estilo `level7`, então nenhum título do exemplo desce mais de um nível em relação ao título anterior: *The trench*, *Tools for the trench*, STEEL BARS e *Rock bar* são dos níveis 4, 5, 6 e 6. O aviso do Sandbox “Salto na hierarquia de títulos” aponta justamente esse tipo de pulo, por isso ele nunca aparece neste capítulo.
- Uma linha de introdução que termina em dois-pontos só fica com a sua lista se o primeiro item couber no espaço que sobra: na versão 1.4.1 a regra verifica uma linha, então um primeiro item de duas linhas que encontra uma única linha livre passa sozinho para a coluna seguinte e deixa os dois-pontos isolados. A seção 1.2 mantém o primeiro item numa linha nas duas edições.

## Créditos

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

## Relacionadas

- [Nº 045 · Títulos laterais, números pendurados e títulos em linha](https://postext.dev/pt/cookbook/side-heads-hanging-numbers.md): Edital de concurso com títulos de seção num canal na margem, sobre as linhas de base do texto, números de subseção na medianiz e títulos em linha em vermelho. · Nível 3 (Avançado) · Relatórios
- [Nº 002 · Artigo em duas colunas com equações numeradas](https://postext.dev/pt/cookbook/journal-article-with-maths.md): Um artigo de física em duas colunas com fórmulas no texto e sete equações numeradas, compostas pelo MathJax da versão ?bundle e vetoriais no PDF. · Nível 3 (Avançado) · Artigos e trabalhos acadêmicos
- [Nº 003 · Abertura de capítulo sobre uma faixa sangrada](https://postext.dev/pt/cookbook/chapter-opener-bleed-band.md): Uma abertura advancedDesign no título de nível 1: faixa sangrada, o número do capítulo sobre o seu fio, e chapéu e linha fina vindos dos atributos do título. · Nível 3 (Avançado) · Livros didáticos
