# PDF acessível e etiquetado (PDF/UA)

> Um guia de reciclagem em PDF/UA-1 etiquetado, com texto alternativo, células de cabeçalho, idioma e sumário com links; a visualização desenha as tags.

- Versão HTML: https://postext.dev/pt/cookbook/accessible-tagged-pdf
- Receita Nº 039 · Saída e integração · Nível 2 (Intermediário) · Saídas: Canvas, PDF
- Gêneros: Relatórios
- Requer postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · testada com 1.19.1, postext-pdf 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/accessible-tagged-pdf/es/p01.webp?v=a42b675b), [2](https://postext.dev/cookbook/accessible-tagged-pdf/es/p02.webp?v=a42b675b), [3](https://postext.dev/cookbook/accessible-tagged-pdf/es/p03.webp?v=a42b675b), [4](https://postext.dev/cookbook/accessible-tagged-pdf/es/p04.webp?v=a42b675b)
- PDF: https://postext.dev/cookbook/accessible-tagged-pdf/es/accessible-tagged-pdf.pdf?v=a42b675b
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=accessible-tagged-pdf&lang=es (.postext: https://postext.dev/cookbook/accessible-tagged-pdf/es/accessible-tagged-pdf.postext)
- Última atualização: 2026-10-01
- Outros idiomas: [en](https://postext.dev/en/cookbook/accessible-tagged-pdf.md), [es](https://postext.dev/es/cookbook/accessible-tagged-pdf.md), [ca](https://postext.dev/ca/cookbook/accessible-tagged-pdf.md), [zh](https://postext.dev/zh/cookbook/accessible-tagged-pdf.md), [ja](https://postext.dev/ja/cookbook/accessible-tagged-pdf.md), [ar](https://postext.dev/ar/cookbook/accessible-tagged-pdf.md)

## Em poucas palavras

Um guia de reciclagem de quatro páginas feito por uma prefeitura. Mostra como criar um PDF que os leitores de tela conseguem seguir, com descrições das imagens e tabelas que eles leem em voz alta.

## O que você vai compor

*Reciclar en el barrio* é o guia de reciclagem de uma prefeitura espanhola inventada: quatro páginas A4 para ler na tela ou imprimir em casa. A capa, azul-marinho, mostra as cinco lixeiras de rua sobre um meio-fio amarelo. A página 2 reúne o sumário e três boxes ao lado de uma carta da vereadora; as páginas 3 e 4 trazem as duas seções, em duas colunas em bandeira de Atkinson Hyperlegible Next. O código exporta um PDF etiquetado que passa nas verificações PDF/UA-1 do veraPDF, com títulos, listas, boxes e tabelas na ordem de leitura, texto alternativo na figura e células de cabeçalho nas duas tabelas. O arquivo declara título e idioma (`es`), e os marcadores e as linhas do sumário levam às páginas. As tags não se imprimem, então o código as desenha sobre a página 3 da visualização, a que aparece aqui, com cada bloco etiquetado numa caixa numerada e identificada pela tag. O PDF baixado não tem essa sobreposição.

**Esta receita responde a:**

- Como exporto um PDF etiquetado (PDF/UA) com texto alternativo, células de cabeçalho e idioma do documento?
- Como faço os rótulos “Figura” e “Tabela” saírem no idioma do meu documento?
- Como acrescento um sumário que se atualiza sozinho (pontilhados, números de página, autores, linhas de parte)?
- Como faço para exportar um PDF de verdade no navegador, com as fontes incorporadas?

## A resposta curta

```js
// script.js, linhas 48–71
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
  caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
  placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
  table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
    rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
      ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
    svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
    caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
    altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
      + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
      + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
  table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
    'Once residuos y el contenedor de cada uno.'),
  table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
    'Qué días y desde qué hora se vacía cada contenedor.'),
  { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
    svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
  resourceBytes: imageBytes }); // accessible and outlines default to true
```

## Ingredientes

**Ensina**

- [PDF acessível com tags](https://postext.dev/pt/docs/configuration.md#geração-de-pdf-configuração): Uma árvore de estrutura PDF/UA-1 na ordem de leitura: títulos, listas, tabelas com cabeçalhos, figuras com texto alternativo, fórmulas, título e idioma.
- [Sumário](https://postext.dev/pt/docs/configuration.md#sumário): Um :::toc gerado a partir dos títulos: linhas pontilhadas, números de página como são impressos, linhas de autor, linhas de parte em cor e entradas clicáveis no PDF.

**Também usa**

- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)
- [Marcadores do PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf-configuração)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Tabelas a partir de dados](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Amostras de cor](https://postext.dev/pt/docs/document-format.md#formatação-em-linha)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Metadados do documento](https://postext.dev/pt/docs/document-format.md#frontmatter)
- [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)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Capas, folhas de rosto e colofões](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Fontes antes da diagramação](https://postext.dev/pt/docs/configuration.md#cache-de-medidas)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Títulos numerados](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível)
- [Sair da grade de propósito](https://postext.dev/pt/docs/architecture.md#elementos-que-rompem-a-grade)
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Geometria por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Quebras de linha nos títulos](https://postext.dev/pt/docs/document-format.md#quebras-de-linha-em-títulos)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`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), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela), [`toc`](https://postext.dev/pt/docs/configuration.md#sumário), [`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), [`decompressWoff2`](https://postext.dev/pt/docs/configuration.md#provedor-de-fontes-no-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`parseTSV`](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha), [`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), [`renderToPdf`](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)

**Tipos**

- Atkinson Hyperlegible Next (OFL-1.1), Atkinson Hyperlegible Mono (OFL-1.1), Public Sans (OFL-1.1)

## Preparo

### 1 · O idioma e o título do documento

```js
// script.js, linhas 127–129
  locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
```

O postext-pdf grava o idioma das tags a partir de `locale`, aqui `/Lang es`, para que um leitor de tela leia o guia com pronúncia espanhola; sem isso, o arquivo declara `en-US`. O `title` e o `author` do frontmatter de `content.es.md` viram o título e o autor do PDF, e o arquivo pede aos visualizadores que mostrem esse título na barra da janela em vez do nome do arquivo. `defaultResourceTypes(LANG)` dá às legendas e às citações seus nomes em espanhol: “Figura 1.1”, “tabla 1.1”. [A resposta curta](https://postext.dev/pt/cookbook/accessible-tagged-pdf.md#a-resposta-curta) dá à figura e às tabelas seu texto acessível: o `altText` vira a descrição alternativa da figura (`/Alt`) e o `/Summary` de cada tabela, e a primeira linha e a primeira coluna das duas tabelas são etiquetadas como células de cabeçalho.

### 2 · Uma só estrutura de títulos, e uma faixa de capa só com o título

```js
// script.js, linhas 75–105
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
  text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
  text('title', '{titleText}', display(28, 'paper'),
    { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
  { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
    placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
  { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
    placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
  text('title', '{titleText}', display(66, 'paper'),
    { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
  advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
  { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
    layout: { layoutType: 'single' }, footer: { elements: [] } },
  plain('indice', { toc: false }), // the contents leave their own heading out
  plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
  levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
    { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
      span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
    { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
      marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
  ] };
```

A árvore de tags tira seus elementos H1 e H2 dos títulos do Markdown, e os marcadores do PDF saem da mesma lista; assim, a capa, o sumário, a carta e as duas seções são de nível 1, e os intertítulos, de nível 2. O postext-pdf etiqueta cada elemento de texto do design de um título como parte desse título. Por isso a faixa da capa leva apenas o título, e a linha fina e o nome da prefeitura vão abaixo dela, como parágrafos. As faixas azul-marinho, o meio-fio e a fileira de lixeiras são elementos de design, e o PDF marca caixas e imagens de design como artefatos, então elas não precisam de texto alternativo e o leitor de tela as pula.

### 3 · Um sumário com links

```js
// script.js, linhas 109–114
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
  numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
  unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
  // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
```

`:::toc` lista os H1 e os H2 com os números de página que eles imprimem, e no PDF cada uma das onze linhas leva ao alto da sua página. As citações `:ref` “figura 1.1” e “tabla 1.1”, na página 3, e “tabla 2.1”, na página 4, levam à figura e às tabelas. As linhas pontilhadas usam a letra dos números de página, Atkinson Hyperlegible Next Bold, porque na Public Sans uma sequência de pontos finais fica mais espaçada que o ponto isolado a partir do qual a linha é calculada, e os pontos invadiam os números.

### 4 · A ordem de leitura, desenhada na página

```js
// script.js, linhas 301–318
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
  resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
  const ids = new Map(); // element → its number
  const add = (block, box) => {
    const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
    if (!ids.has(key)) ids.set(key, ids.size + 1);
    return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
  };
  const blocks = page.columns.flatMap((c) => c.blocks);
  const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []),
  ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
  ...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
```

Um leitor de tela segue as tags, e `renderToPdf` etiqueta cada página na ordem em que a pinta: o título da faixa, a primeira coluna de cima para baixo, a segunda e, por fim, os flutuantes da página. Esta função repete essa ordem para numerar as caixas da página 3, e a árvore de estrutura do PDF lista os mesmos catorze elementos na mesma ordem. É por causa dessa ordem que a figura e as duas tabelas ficam no fluxo, com `placement: { position: 'here' }` e uma linha `::resource`. Flutuando no pé da primeira coluna, a figura 1.1 seria etiquetada depois da tabela 1.1, o último bloco da página. O parágrafo sobre o vidro que continua na segunda coluna mantém um só número, 9, porque o PDF etiqueta as duas partes como um único parágrafo.

### 5 · Cores escolhidas pelo contraste

```js
// script.js, linhas 16–35
const palette = {
  ink: '#14243a', // text: 15.6:1 on white
  navy: '#173556', // bands: white type on it 12.5:1
  signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
  tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
  rule: '#aebccb', // table rules
  muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
  paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
  gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
  punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
  ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
```

O PDF/UA-1 não verifica cor, mas a WCAG 2.2 pede 4,5:1 entre o texto e o fundo. A tinta mede 15,6:1 sobre branco, a letra branca 12,5:1 sobre as faixas azul-marinho e o cinza do rodapé 7,0:1. O amarelo dá 1,8:1 sobre branco, por isso só aparece sobre o azul-marinho (6,9:1) e como preenchimento. Os pictogramas seguem o 3:1 que a WCAG pede para gráficos: branco quando atinge essa proporção contra a lixeira, tinta sobre a amarela. `cell()` escreve o nome de cada lixeira ao lado da sua amostra de cor e dá ao ecoponto um quadrado vazio, de modo que nenhuma instrução depende de distinguir cores.

## 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/accessible-tagged-pdf

### script.js

```js
// ═══ Postext Cookbook · Nº 039 · Accessible tagged PDF (PDF/UA) ═════════════════════
// https://postext.dev/en/cookbook/accessible-tagged-pdf
// Code: MIT · Text: original (CC BY 4.0) · Pictograms: generated in code (CC BY 4.0)
// Fonts: Atkinson Hyperlegible Next and Mono, Public Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseTSV,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'accessible-tagged-pdf';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: text colours chosen for their contrast, and never a colour without its name
const palette = {
  ink: '#14243a', // text: 15.6:1 on white
  navy: '#173556', // bands: white type on it 12.5:1
  signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
  tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
  rule: '#aebccb', // table rules
  muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
  paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
  gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
  punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
  ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
// #endregion

const TRIM = [210, 297]; // A4, the size residents print at home
const [TOP, FOOT, SIDE, LEAD] = [20, 22, 18, 15.5]; // margins in mm, not mirrored; leading in pt
const [PX, FIGURE, STREET] = [12, [83, 30], [174, 44]]; // drawings: px per mm, sizes in mm
const face = (fontFamily, fontWeight, size, more) => ({ fontFamily, fontWeight, fontSize: pt(size),
  ...more });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // default: '…' (gotcha: overflow-ellipsis-default)

// #region answer: the alt text and header cells the tags take from the resources
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
  caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
  placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
  table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
    rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
      ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
    svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
    caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
    altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
      + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
      + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
  table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
    'Once residuos y el contenedor de cada uno.'),
  table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
    'Qué días y desde qué hora se vacía cada contenedor.'),
  { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
    svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
  resourceBytes: imageBytes }); // accessible and outlines default to true
// #endregion

// #region headings: one outline: cover and contents unnumbered, then sections 1 and 2 with H2s
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
  text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
  text('title', '{titleText}', display(28, 'paper'),
    { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
  { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
    placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
  { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
    placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
  text('title', '{titleText}', display(66, 'paper'),
    { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
  advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
  { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
    layout: { layoutType: 'single' }, footer: { elements: [] } },
  plain('indice', { toc: false }), // the contents leave their own heading out
  plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
  levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
    { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
      span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
    { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
      marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
  ] };
// #endregion

// #region contents: the H1s and H2s with their page numbers; each row links in the PDF
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
  numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
  unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
  // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
// #endregion

const label = (size, color, weight = 400) => face('Atkinson Hyperlegible Mono', weight, size,
  { color: col(color) });
const footer = { elements: [ // the PDF tags these as pagination artifacts
  text('where', '{title} · Castrovalle', { ...label(7.5, 'muted'), letterSpacing: pt(0.6),
    overflow: 'clip' }, at('container', 'bottom-left', 0, -11)),
  text('folio', '{pageNumber}', { ...label(9, 'ink', 700), align: 'right', overflow: 'clip' },
    at('container', 'bottom-right', 0, -10.6))] };

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  // #region identity: the language the PDF declares, and captions in that language
  locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
  // #endregion
  colorPalette, headings, headingStyles, toc, footer,
  header: { elements: [] }, // each page opens with an H1, so the folio goes in the footer
  page: { width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(SIDE), right: mm(SIDE) } },
  layout: { gutterWidth: mm(8) }, // two columns: the default layout
  bodyText: { fontFamily: 'Atkinson Hyperlegible Next', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
    firstLineIndent: mm(0), paragraphSpacing: true }, // ragged, so no runt check: ragged-runts
  unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(LEAD) },
  orderedLists: { color: col('ink'), fontWeight: 700, marginTop: pt(0), marginBottom: pt(LEAD) },
  paragraphStyles: [{ id: 'entradilla', fontSize: pt(17), lineHeight: pt(24) },
    { id: 'carta', fontSize: pt(12), lineHeight: pt(18), spaceBetween: pt(9) },
    // the council's imprint drops seven lines below the lead, to the cover's last line
    { id: 'sello', ...label(9, 'ink'), lineHeight: pt(LEAD), marginTop: pt(7 * LEAD) },
    { id: 'colofon', ...label(7, 'muted'), lineHeight: pt(10), marginTop: pt(LEAD) }],
  calloutStyles: [{ id: 'formatos', background: col('tint'), padding: mm(4), snapToGrid: false,
    titleStyle: { ...label(8, 'navy', 700), letterSpacing: pt(0.6), textTransform: 'uppercase' },
    marginTop: mm(4.5), body: { fontSize: pt(10), lineHeight: pt(14.5) } }],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('tint'), headerColor: col('ink'), headerFontSize: pt(9.5),
    bodyFontSize: pt(9.5), cellPadding: mm(1.35) }, // faces and colours follow the body text
  captionStyle: { fontSize: pt(9) },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Reciclar en el barrio"
subtitle: "Guía de residuos del barrio de la Estación"
author: "Ayuntamiento de Castrovalle"
---

# Reciclar \\ en el barrio {style="portada"}

:::paragraphs{style="entradilla"}
Dónde va cada residuo del barrio de la Estación, del brik de leche al sofá viejo, y a qué hora pasa cada camión.
:::

:::paragraphs{style="sello"}
**Ayuntamiento de Castrovalle** · Concejalía de Medio Ambiente · Enero de 2026
:::

# Índice {style="indice"}

:::toc

:::callout{type="formatos" title="Cómo usar esta guía"}
La sección 1 recorre la isla de contenedores de izquierda a derecha, y su tabla resuelve los residuos que más dudas dan. La sección 2 trata lo que no va al contenedor: el punto limpio, los horarios de recogida y la retirada de muebles.

Cada cuadrado de color de las tablas lleva al lado el nombre de su contenedor, para que nadie tenga que distinguir los colores.
:::

:::callout{type="formatos" title="Esta guía, en otros formatos"}
Este PDF está etiquetado. Un lector de pantalla recorre sus títulos, listas y tablas en el orden de lectura y lee la descripción de cada figura, y cada fila del índice lleva a su página.

Si la prefieres en letra grande, en lectura fácil o en papel, pídela en el 010 o en la Oficina de Atención a la Ciudadanía. Te la enviamos a casa.
:::

:::callout{type="formatos" title="Datos útiles"}
- **Información municipal:** 010, de lunes a sábado, de 8:00 a 20:00.
- **Punto limpio:** avenida de los Álamos, 14.
- **Muebles y enseres:** cita previa en el 010.
- **Atención a la Ciudadanía:** plaza Mayor, 1.
:::

:::columnbreak

# Presentación {style="presentacion"}

:::paragraphs{style="carta"}
En el barrio de la Estación viven unas 6400 personas, y cada una tira algo más de un kilo de basura al día. Hasta hace un año, casi todo acababa en el mismo sitio. Desde marzo hay contenedor marrón en todas las calles, y con él son cinco los contenedores de cada isla.

Esta guía explica qué va en cada contenedor y qué hacer con lo que no cabe en ninguno: el sofá viejo, el aceite de la sartén, las pilas o una lámpara rota. Los horarios de recogida y la dirección del punto limpio están en la última página.

Lo que se echa al contenedor marrón se convierte en compost en la planta de la comarca y vuelve a los parques y jardines del municipio. Para que ese compost sirva, la materia orgánica tiene que llegar limpia, sin bolsas de plástico ni restos de vidrio.

En la planta de clasificación, un envase echado al contenedor equivocado se aparta a mano. Una bolsa de basura en el contenedor del papel moja y mancha el cartón, y puede echar a perder la carga entera del camión.

Entre marzo y diciembre, el barrio llevó al contenedor marrón 312 toneladas de restos de comida, una de cada siete toneladas de la basura que tiró en esos meses. Este año queremos llegar a una de cada cuatro, y para eso basta con que cada casa separe sus restos de comida.

*Marta Ibarra, concejala de Medio Ambiente*
:::

# Qué va en cada contenedor

En cada calle del barrio hay una isla de cinco contenedores, siempre en el mismo orden, el de la :ref{id="contenedores" style="full" case="lower"}. Cada color recoge un tipo de residuo, pero no todo el plástico va al amarillo ni todo el vidrio al verde.

::resource{id="contenedores"}

## Amarillo: envases

Envases de plástico, latas y briks: botellas de agua y de refresco, botes de champú y de detergente, latas de conserva y de bebida, bolsas de plástico, bandejas de corcho blanco, papel de aluminio, chapas y tapas de metal. Vacíalos antes de tirarlos; no hace falta lavarlos. Un juguete o un cubo no son envases, aunque sean de plástico, y van al contenedor gris.

## Azul: papel y cartón

Periódicos, revistas, folletos, sobres, cajas de cartón y hueveras de cartón. Pliega las cajas antes de echarlas, para que quepan más. Las servilletas y el papel de cocina usados van al marrón, y los tiques de compra, de papel térmico, al gris.

## Verde: vidrio

Botellas, tarros y frascos de vidrio, sin tapas ni tapones: los de metal y plástico van al amarillo, y los de corcho, al marrón. Los vasos, las copas y los platos no son vidrio de envase: funden a otra temperatura y estropean el vidrio que se recicla. Van al gris, y los espejos y los cristales de ventana, al punto limpio.

## Marrón: orgánico

Restos de comida, crudos o cocinados: mondas de fruta, cáscaras de huevo, espinas, posos de café e infusiones, flores secas, tapones de corcho y papel de cocina sucio. Usa bolsas compostables, con la marca de la norma UNE-EN 13432: en la planta, las bolsas de plástico se retiran a mano antes de hacer el compost.

## Gris: resto

Lo que no va en ninguno de los otros cuatro: pañales y compresas, colillas, excrementos de mascotas y arena del gato, polvo de barrer, chicles, cerámica y loza rota. Antes de echar algo al gris, busca en la :ref{id="dudas" style="full" case="lower"} si tiene un sitio mejor, y echa siempre la bolsa cerrada.

::resource{id="dudas"}

# Lo que no va al contenedor

Algunos residuos no caben en ninguna isla, por su tamaño o porque contaminan. Se llevan al punto limpio, se dejan en los puntos de recogida de las tiendas o se recogen a domicilio.

## El punto limpio

Está en la avenida de los Álamos, 14, junto a la rotonda del polígono, y abre de martes a sábado, de 9:00 a 14:00 y de 16:00 a 19:30. Allí se dejan, sin coste para los vecinos:

- aceite de cocina usado, en una botella de plástico cerrada;
- pilas, baterías y bombillas;
- pequeños aparatos eléctricos, como secadores, móviles o cargadores;
- pintura, disolventes y aerosoles con restos;
- espejos, cristales de ventana y radiografías;
- restos de poda, en sacos de hasta 25 kilos.

Las pilas y las bombillas también se pueden dejar en los contenedores de las tiendas que las venden, y los medicamentos, con su caja, en cualquier farmacia. La ropa y el calzado usados tienen sus propios contenedores, en la plaza del Mercado y junto al centro de salud.

## Cuándo pasa cada camión

Los contenedores se vacían de noche, salvo el del vidrio, que se vacía por la mañana. La :ref{id="horarios" style="full" case="lower"} da el día y la hora de cada recogida. Echa las bolsas a partir de las 20:00, para que pasen el menor tiempo posible en la calle, y el vidrio, de día, porque hace ruido.

::resource{id="horarios"}

## Muebles y enseres

Un colchón, una silla o una lavadora no se dejan junto a los contenedores. Si compras un electrodoméstico nuevo, la tienda se lleva el viejo sin cobrarte nada. Lo demás lo retira gratis el servicio municipal, en la puerta de casa:

1. Llama al 010 o pide cita en la sede electrónica del Ayuntamiento.
2. Apunta el día y el número de recogida.
3. Pega en cada objeto un papel con ese número.
4. Esa noche, desde las 21:00, saca los objetos a la acera de tu portal.

Si tienes una duda que esta guía no resuelve, llama al 010, de lunes a sábado de 8:00 a 20:00, o pregunta en el punto limpio. Las preguntas que más se repitan entrarán en la próxima edición.

:::paragraphs{style="colofon"}
Castrovalle es un municipio imaginario, y esta guía se escribió para el Recetario de Postext (postext.dev). Compuesta en Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono y Public Sans (SIL Open Font License). Texto e ilustraciones: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
const dudas = String.raw`Residuo	Contenedor
Brik de leche o de zumo	amarillo
Tapa de un tarro de cristal	amarillo
Papel de aluminio	amarillo
Bolsa de patatas fritas	amarillo
Caja de pizza	azul
Frasco de colonia	verde
Servilleta de papel usada	marron
Vaso o copa de cristal	gris
Tique de compra	gris
Bombilla	punto
Aceite de cocina usado	punto
`; // TSV, as a spreadsheet exports it: residuo, contenedor
const horarios = String.raw`Contenedor	Días	Desde
amarillo	Lunes, miércoles y viernes	22:00
azul	Martes y sábados	22:00
verde	Jueves alternos	8:00
marron	Todos los días	23:00
gris	Todos los días	23:00
`; // TSV: contenedor, días, desde qué hora

// #region reading-order: the order of the tags, which is the order renderToPdf paints the page in
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
  resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
  const ids = new Map(); // element → its number
  const add = (block, box) => {
    const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
    if (!ids.has(key)) ids.set(key, ids.size + 1);
    return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
  };
  const blocks = page.columns.flatMap((c) => c.blocks);
  const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []),
  ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
  ...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
// #endregion

// #region art: the five street bins, and the reading order painted over page 3
const n = (v) => Math.round(v * 100) / 100;
const rgb = (hex) => hex.slice(1).match(/../g).map((c) => parseInt(c, 16));
const shade = (hex, k) => `#${rgb(hex).map((c) => Math.round(c * k).toString(16).padStart(2, '0'))
  .join('')}`;
const luminance = (hex) => rgb(hex).map((c) => c / 255).map((c) => (c <= 0.03928 ? c / 12.92
  : ((c + 0.055) / 1.055) ** 2.4)).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
// A white glyph where it reaches 3:1 against the bin (WCAG's figure for graphics), else ink.
const glyphOn = (hex) => (1.05 / (luminance(hex) + 0.05) >= 3 ? '#ffffff' : palette.ink);
// Pictograms in an 8 × 10 box centred on (0, 0): a bottle, a box, a wine bottle, an apple core
// and a tied bag. Strokes only, so they stay vector in the PDF.
const GLYPH = {
  amarillo: 'M-1.2 -5L1.2 -5L1.2 -3.6C2.8 -3 3 -2 3 -1L3 4.2C3 4.8 2.6 5 2 5L-2 5C-2.6 5 -3 4.8 '
    + '-3 4.2L-3 -1C-3 -2 -2.8 -3 -1.2 -3.6Z M-3 0.6L3 0.6',
  azul: 'M-4 -1L0 -3L4 -1L4 4L0 5.6L-4 4Z M-4 -1L0 1L4 -1 M0 1L0 5.6 M-4 -1L-5 -3.4L-1 -5.4L0 -3',
  verde: 'M-0.9 -5.4L0.9 -5.4L0.9 -2.2C2.6 -1.4 2.8 -0.4 2.8 0.8L2.8 4.6C2.8 5.1 2.5 5.4 2 5.4L-2 '
    + '5.4C-2.5 5.4 -2.8 5.1 -2.8 4.6L-2.8 0.8C-2.8 -0.4 -2.6 -1.4 -0.9 -2.2Z',
  marron: 'M-2.6 -3.2C-0.6 -3.8 0.6 -3.8 2.6 -3.2C1.2 -1.6 1.2 1.6 2.6 3.6C0.6 4.4 -0.6 4.4 '
    + '-2.6 3.6C-1.2 1.6 -1.2 -1.6 -2.6 -3.2Z M0 -3.6L0.4 -5.6 M0.4 -5C1.6 -6 2.8 -5.6 3.2 -5',
  gris: 'M-3.4 -1.6C-3.8 1.4 -3.4 5 0 5C3.4 5 3.8 1.4 3.4 -1.6C2.6 -2.6 1 -3 0 -3.2C-1 -3 '
    + '-2.6 -2.6 -3.4 -1.6Z M-1.6 -3.1L-2.4 -5.2L0 -4L2.4 -5.2L1.6 -3.1',
};
function binsSvg([w, h], size) { // five bins across w mm, their wheels on the foot of the drawing
  const step = w / 5;
  const bins = Object.keys(BIN).map((id, i) => {
    const [cx, bw, r] = [step * (i + 0.5), size * 0.68, size * 0.06]; // centre, width, wheel
    const [top, foot] = [h - size - r, h - r];
    const lid = `M${n(cx - bw / 2 - r / 2)} ${n(top)}L${n(cx + bw / 2 + r / 2)} ${n(top)}`
      + `L${n(cx + bw / 2)} ${n(top - size * 0.1)}L${n(cx - bw / 2)} ${n(top - size * 0.1)}Z`;
    const body = `M${n(cx - bw / 2)} ${n(top)}L${n(cx + bw / 2)} ${n(top)}L${n(cx + bw * 0.46)} `
      + `${n(foot)}L${n(cx - bw * 0.46)} ${n(foot)}Z`;
    const wheel = (x) => `<circle cx="${n(x)}" cy="${n(foot)}" r="${n(r)}" fill="${palette.ink}"/>`;
    return `<path d="${body}" fill="${BIN[id]}"/><path d="${lid}" fill="${shade(BIN[id], 0.72)}"/>`
      + wheel(cx - bw * 0.34) + wheel(cx + bw * 0.34)
      + `<path d="${GLYPH[id]}" transform="translate(${n(cx)} ${n(top + size * 0.48)}) `
      + `scale(${n(size / 16)})" fill="none" stroke="${glyphOn(BIN[id])}" stroke-width="0.8" `
      + 'stroke-linejoin="round" stroke-linecap="round"/>'; // the glyphs are drawn for a 16 mm bin
  });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" `
    + `viewBox="0 0 ${w} ${h}">${bins.join('')}</svg>`;
}
function union(boxes) { // the box around several design elements
  return boxes.reduce((u, { bbox: b }) => {
    const [x, y] = [Math.min(u.x, b.x), Math.min(u.y, b.y)];
    return { x, y, width: Math.max(u.x + u.width, b.x + b.width) - x,
      height: Math.max(u.y + u.height, b.y + b.height) - y };
  }, boxes[0].bbox);
}
function drawReadingOrder(ctx, page, scale) {
  const mmPx = (v) => (v * page.width * scale) / TRIM[0];
  const [r, pad, ink] = [mmPx(2.8), mmPx(1.2), '#d6146e']; // disc radius, box padding, magenta
  const inset = mmPx(0.6); // a gap under a box another one touches, such as a heading
  const boxOf = ({ x, y, width, height }, grow = 0) => ({ x: x * scale - pad, y: y * scale
    + inset - grow, w: width * scale + 2 * pad, h: height * scale - inset + 2 * grow });
  const tab = (label, { x, y, w }, fill) => { // a label straddling the box's top-right corner
    ctx.font = `700 ${r * 0.95}px "Atkinson Hyperlegible Mono"`;
    const lw = ctx.measureText(label).width + r * 0.8;
    ctx.fillStyle = fill;
    ctx.fillRect(x + w - lw, y - r * 0.55, lw, r * 1.1);
    ctx.fillStyle = '#ffffff';
    ctx.fillText(label, x + w - lw / 2, y + r * 0.02);
  };
  const marks = readingOrder(page).map((m) => ({ ...m, ...boxOf(m.box) }));
  ctx.save();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.lineWidth = r / 4.5;
  ctx.strokeStyle = ink;
  marks.forEach((m, i) => { // a line down each column, from one number to the next
    const p = marks[i - 1];
    if (!p || m.y < p.y) return; // no line for the jump to the next column
    ctx.beginPath();
    ctx.moveTo(p.x - r * 1.3, p.y + r);
    ctx.lineTo(m.x - r * 1.3, m.y + r);
    ctx.stroke();
  });
  for (const m of marks) {
    ctx.fillStyle = 'rgba(214, 20, 110, 0.07)';
    ctx.fillRect(m.x, m.y, m.w, m.h);
    ctx.setLineDash(m.cont ? [r / 2, r / 3] : []);
    ctx.strokeRect(m.x, m.y, m.w, m.h);
    ctx.setLineDash([]);
    ctx.fillStyle = ink;
    ctx.beginPath();
    ctx.arc(m.x - r * 1.3, m.y + r, r, 0, Math.PI * 2);
    ctx.fill();
    tab(m.cont ? `${m.tag} (cont.)` : m.tag, m, ink);
    ctx.font = `700 ${r * 1.15}px "Atkinson Hyperlegible Mono"`;
    ctx.fillText(String(m.n), m.x - r * 1.3, m.y + r * 1.05);
  }
  if (page.footer?.blocks.length) { // running heads and folios: artifacts, never read
    const foot = boxOf(union(page.footer.blocks), pad);
    ctx.strokeStyle = '#6b7480';
    ctx.setLineDash([r / 2, r / 3]);
    ctx.strokeRect(foot.x, foot.y, foot.w, foot.h);
    tab('Artifact', foot, '#6b7480');
  }
  ctx.restore();
}
function showReadingOrder(page) { // over the viewer's page, and in the Cookbook's page images
  const canvas = [...document.querySelectorAll('#pages canvas')]
    .find((c) => c.postext.page === page);
  const layer = Object.assign(document.createElement('canvas'), { width: canvas.clientWidth * 2,
    height: canvas.clientHeight * 2 }); // transparent, over the painted page
  layer.style.cssText = 'position:absolute;top:0;left:0;width:100%;background:none;box-shadow:none';
  canvas.parentElement.style.position = 'relative';
  canvas.after(layer);
  drawReadingOrder(layer.getContext('2d'), page, layer.width / page.width);
  window.__postextOverlay = (ctx, p, _, scale) => p.index === page.index
    && drawReadingOrder(ctx, p, scale);
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before layout (gotcha: fonts-first); the PDF embeds the same files (gotcha: latin-subset)
const FONTS = { 'Atkinson Hyperlegible Next': ['400', '400i', '700', '700i'],
  'Atkinson Hyperlegible Mono': ['400', '700'], 'Public Sans': ['700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('contenedores.svg', binsSvg(FIGURE, 23));
await loadSvg('calle.svg', binsSvg(STREET, 36));
const content = { markdown, resources: resources() };
const doc = await buildWithFonts(() => buildDocument(content, config()), markdown);
showPages(doc, { title: 'Reciclar en el barrio · PDF accesible' });
showReadingOrder(doc.pages[2]); // page 3: every tagged block numbered in reading order
offerPdf(() => exportPdf(doc), `${RECIPE}.pdf`);

// ─── 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 · pdf v2 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType: the nearest weight the
 *  family ships, upright if it has no italic; latin, then what the face's
 *  letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const more = kitSubsetsFor(text, meta);
  const files = await Promise.all(['latin', ...more].map(async (subset) => {
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
  return files.length === 1 ? files[0] : files;
}

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

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

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

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

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

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

## Variações

### Deixe a figura flutuar

Tire o posicionamento e a figura 1.1 flutua para o pé da primeira coluna da página 3, e o PDF a etiqueta depois da tabela 1.1, o último bloco da página.

```diff
-  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
+  { id: 'contenedores', typeId: 'figure', kind: 'svg',
```

## Erros comuns

- **Uma figura flutuante é lida depois do texto da sua página.** renderToPdf marca cada página na ordem em que a pinta: o título da faixa de abertura, cada coluna de cima para baixo e, por fim, os flutuantes da página. Uma figura ou tabela que flutua para o alto de uma coluna é lida, portanto, depois do último parágrafo e da última tabela da página, longe da frase que a cita. Coloque no texto as que o leitor precisa encontrar onde são citadas, com placement 'here' e uma linha ::resource.
- **O pontilhado do sumário transborda numa fonte que afasta pontos seguidos.** postext 1.4.1 calcula os pontos de um pontilhado pela largura de um ponto só e os compõe na fonte dos números de página. Algumas fontes afastam mais uma sequência de pontos (Public Sans Bold a 12 pt: 6,7 px para um ponto isolado a 150 dpi, 7,6 px para cada ponto de uma sequência), então os pontos passam do seu espaço, encostam no número da página e deixam de se alinhar de uma linha para outra. Dê a toc.pageNumber um fontFamily cujos pontos mantenham a largura em sequência, como a fonte do texto corrido.
- **Texto em bandeira nunca é verificado contra linhas curtas.** optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta.
- **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".
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **Texto alinhado à esquerda nunca é hifenizado.** A hifenização só se aplica ao texto justificado; o texto alinhado à esquerda quebra entre palavras, então uma coluna estreita em bandeira fica muito irregular. Justifique o trecho ou aumente a medida.
- **Um id desconhecido em :ref imprime “?” sem aviso do motor.** Um :ref para um id que nenhum recurso tem imprime “?” e não posiciona nada, e só o Sandbox avisa. Confira se cada id citado existe.
- **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 estilo de título herda a quebra de página do seu nível.** Uma entrada de headingStyles recebe do seu nível de título todos os campos que não define, inclusive breakBefore. Um sumário ou um colofão com estilo sobre um H1 depois de um :::pagebreak herda a paridade 'odd' e cai depois de uma página em branco. Dê a esse estilo breakBefore: { enabled: false }.
- **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.
- **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.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **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.
- **Os arquivos do Fontsource cobrem letras, não símbolos.** O kit incorpora o arquivo latin do Fontsource de cada fonte e, quando o texto usa esses caracteres, o arquivo latin-ext para č, ł, † e o arquivo greek para α, χ (em uma família que o tenha: Lora e Gelasio não têm), na tela e no PDF. Símbolos como →, ≈, ✓ e ★ não estão em nenhum desses arquivos e somem no PDF: desenhe-os, componha-os como matemática ou escolha uma fonte cujos arquivos os tenham.

O código não consegue validar o próprio PDF, porque o veraPDF roda fora do navegador. Baixe o arquivo e rode `verapdf -f ua1 accessible-tagged-pdf.pdf`; a cópia capturada para esta página passa. O validador não sabe dizer se um texto alternativo descreve a imagem, então ouça o guia também com um leitor de tela.

Este guia foi capturado com o postext 1.4.1, em que um link de Markdown imprimia as palavras sem acrescentar nenhum link ao PDF; por isso o colofão imprime o endereço da web completo. A partir do postext 1.10, um link `[text](url)` funciona no PDF e é etiquetado como um elemento `Link` cujo texto são as palavras com o link.

## Créditos

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

## Relacionadas

- [Nº 024 · PDF para gráfica: sangria, marcas de corte e PDF/X](https://postext.dev/pt/cookbook/print-ready-pdf.md): Um folheto de exposição com 3 mm de sangria e marcas de corte, exportado em PDF/X-4 sobre FOGRA51 com matrizes de impressão e preflight limpo. · Nível 3 (Avançado) · Folhas avulsas e impressos efêmeros
- [Nº 025 · Um PDF de verdade com as mesmas fontes incorporadas](https://postext.dev/pt/cookbook/pdf-with-embedded-fonts.md): O programa de um recital exportado em PDF. Cada fonte é baixada uma só vez, para o FontFace e para o PDF, e cada título vira um marcador. · Nível 2 (Intermediário) · Folhas avulsas e impressos efêmeros
- [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
