# Um PDF de verdade com as mesmas fontes incorporadas

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

- Versão HTML: https://postext.dev/pt/cookbook/pdf-with-embedded-fonts
- Receita Nº 025 · Saída e integração · Nível 2 (Intermediário) · Saídas: Canvas, PDF
- Gêneros: Folhas avulsas e impressos efêmeros
- 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/pdf-with-embedded-fonts/en/p01.webp?v=b17656f6), [2](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p02.webp?v=b17656f6), [3](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p03.webp?v=b17656f6), [4](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p04.webp?v=b17656f6)
- PDF: https://postext.dev/cookbook/pdf-with-embedded-fonts/en/pdf-with-embedded-fonts.pdf?v=b17656f6
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=pdf-with-embedded-fonts&lang=en (.postext: https://postext.dev/cookbook/pdf-with-embedded-fonts/en/pdf-with-embedded-fonts.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [en](https://postext.dev/en/cookbook/pdf-with-embedded-fonts.md), [es](https://postext.dev/es/cookbook/pdf-with-embedded-fonts.md), [ca](https://postext.dev/ca/cookbook/pdf-with-embedded-fonts.md), [zh](https://postext.dev/zh/cookbook/pdf-with-embedded-fonts.md), [ja](https://postext.dev/ja/cookbook/pdf-with-embedded-fonts.md), [ar](https://postext.dev/ar/cookbook/pdf-with-embedded-fonts.md)

## Em poucas palavras

O programa de quatro páginas de um concerto ao entardecer. Mostra como salvá-lo num PDF idêntico à tela, com as fontes dentro e cada título na lista de marcadores para pular até ele.

## O que você vai compor

O programa de um recital ao entardecer, *Home from Sea*: quatro páginas A5 para um conjunto de soprano, violoncelo e harpa. A capa é azul-petróleo noturno, com o título em Fraunces itálico dourado e fileiras de escamas de onda douradas subindo do pé. Por dentro, a ordem do programa é uma tabela com cabeçalho azul-petróleo em versais de Tenor Sans e uma linha de total com fundo tingido. As notas estão em Crimson Text justificado. As letras das três canções são poemas de Longfellow, Stevenson e Tennyson, compostos com os recuos das edições impressas. O botão *Build the PDF* gera o arquivo que você mandaria ao público por e-mail ou publicaria no site da sala de concertos. As quatro páginas batem linha a linha com a tela. O arquivo incorpora só os sete arquivos de fonte que o navegador carregou, e cada título é um marcador. Para impressão em gráfica, veja [o PDF pronto para impressão](https://postext.dev/pt/cookbook/print-ready-pdf.md).

**Esta receita responde a:**

- Como faço para exportar no navegador um PDF de verdade, com marcadores e exatamente as fontes da página?
- Por que as minhas quebras de linha mudam ou as palavras se sobrepõem no PDF, e como carrego as fontes corretamente?

## A resposta curta

```js
// script.js, linhas 15–51
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
```

## Ingredientes

**Ensina**

- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes): Um provedor de fontes entrega a renderToPdf os bytes TrueType estáticos de cada fonte que o layout mediu, para que o PDF componha exatamente as mesmas linhas.
- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf): renderToPdf transforma o documento diagramado, ou um livro inteiro, nos bytes de um PDF no navegador, informando o progresso nos livros longos.
- [Marcadores do PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf-configuração): Uma árvore de marcadores montada a partir dos títulos, com as partes acima dos seus capítulos.

**Também usa**

- [Fontes antes da diagramação](https://postext.dev/pt/docs/configuration.md#cache-de-medidas)
- [Tipografia do texto](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Metadados do documento](https://postext.dev/pt/docs/document-format.md#frontmatter)
- [Capas, folhas de rosto e colofões](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)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [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)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de tabela](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space)

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

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

- Crimson Text (OFL-1.1), Fraunces (OFL-1.1), Tenor Sans (OFL-1.1)

## Preparo

### 1 · Um download por fonte, para a página e para o PDF

O código é [a resposta curta](https://postext.dev/pt/cookbook/pdf-with-embedded-fonts.md#a-resposta-curta) logo acima. O Postext mede cada palavra com as fontes que o navegador tem carregadas no momento da composição, e o PDF desenha cada linha onde essa diagramação a deixou. Se o PDF incorporar outro arquivo, como a instância padrão de uma fonte variável ou uma fonte substituta do sistema, cada palavra continua começando na posição medida, mas as letras têm outras larguras, e as palavras se sobrepõem ou deixam buracos. Por isso a receita baixa cada fonte uma vez só. Os bytes viram um `FontFace` antes da primeira composição, e o provedor de fontes passa esses mesmos bytes por `decompressWoff2` para o PDF, de modo que a exportação não baixa nenhuma fonte por conta própria.

### 2 · Primeiro as fontes, depois a diagramação

```js
// script.js, linhas 363–367
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });
```

`registerFaces()` só se resolve quando todas as fontes de `FONTS` estão carregadas, então a primeira composição já mede com as fontes que o PDF vai incorporar. Ela também é a única, porque `FONTS` lista cada negrito e cada itálico que um bloco de Crimson Text ou de Fraunces pode pedir, e o `buildWithFonts` do kit não encontra nada para acrescentar ([Cache de medidas](https://postext.dev/pt/docs/configuration.md#cache-de-medidas)). Essa verificação só ajuda a tela. Se faltar uma fonte em `FONTS`, o `buildWithFonts` a carrega e refaz a diagramação, com um aviso no console quando um bloco é composto nessa fonte e sem aviso nenhum quando é um negrito ou um itálico, mas o PDF continua recebendo a fonte mais próxima que o provedor tiver.

### 3 · A exportação e a lista do que foi incorporado

```js
// script.js, linhas 371–386
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);
```

`renderToPdf` chama `onProgress` em três fases: `prepare` depois de incorporar as fontes e a capa, `pages` depois de cada página e `save` antes de gravar o arquivo. Aqui ele pede dez fontes ao provedor. A linha de status lista os sete arquivos que o provedor serviu, exatamente os de `FONTS`, e três substitutas, todas de Tenor Sans, família que só tem o regular 400 e que aqui nunca aparece em negrito nem em itálico. Qualquer outra substituta denuncia uma fonte que falta em `FONTS`. Se você tirar a Crimson Text 600, a linha mostra `Crimson Text 600 → 400` e o negrito de *The Harbour Consort*, na página 4, sai com peso regular. A visualização do CodePen não consegue mostrar um PDF, então o `offerPdf` do kit troca o botão por dois links, um que abre o PDF numa nova aba e outro que o baixa ([Geração de PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)).

### 4 · A árvore de títulos é a árvore de marcadores

```js
// script.js, linhas 108–116
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
```

No PDF, o painel de marcadores mostra o título da capa, *Programme* com *About the music* embaixo, *The texts* com as três canções e *The performers*. Os marcadores seguem os níveis de título, então escolha esses níveis pensando neles: aqui, um H1 por seção e um H2 por canção. *The performers* tem um estilo de título próprio, um H1 sem quebra de página e sem abertura, de modo que divide a página 4 com o poema de Tennyson e ainda assim ganha um marcador de primeiro nível. O `\\` que quebra em duas linhas o título da capa vira um espaço no marcador.

### 5 · Título e autor a partir do frontmatter

```js
// script.js, linhas 80–93
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
```

Todos os valores do frontmatter vão entre aspas. A capa imprime dois deles com `{author}` e `{subtitle}`, e as propriedades do documento PDF tiram do mesmo bloco o título, *Home from Sea*, e o autor, *The Harbour Consort*. A data e o local são atributos do título da capa. O desenho atrás deles é um único SVG da largura da página, e o PDF o mantém como traçados vetoriais.

## 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/pdf-with-embedded-fonts

### script.js

```js
// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════
// https://postext.dev/en/cookbook/pdf-with-embedded-fonts
// Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code
// Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'pdf-with-embedded-fonts';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one download per face: the layout measures it, the PDF embeds it
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles
  gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper
  foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines
  muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults'
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue

const PAGE = { width: 148, height: 210 }; // mm: an A5 programme
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure
const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body
const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking
const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // a multiple (gotcha: design-lineheight-multiple)
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

// #region cover: the drawing fills the page; the frontmatter and the heading set the type
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
// #endregion

const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule
  text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')),
  text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'),
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } },
] } };

const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity,
  ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x),
    y: mm(-MARGIN.bottom / 2) } } });

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  colorPalette, resourceTypes: [plain], headings, headingStyles: [cover, aside],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 },
  layout: { layoutType: 'single', inlineResourceGap: 'above' }, // no line under the table
  paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over
    { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) },
    { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans',
    headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) },
  header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up
    foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme
    foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Home from Sea"
subtitle: "Three new songs and older water music for soprano, cello and harp"
author: "The Harbour Consort"
---

# Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"}

# Programme {kicker="Twilight recital · 17 October 2026"}

::resource{id="order"}

## About the music

Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung.

Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song.

Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle.

# The texts {kicker="Home from Sea · three songs"}

## The Tide Rises, the Tide Falls

:::paragraphs{style="verse"}
The tide rises, the tide falls,

The twilight darkens, the curlew calls;

Along the sea-sands damp and brown

The traveller hastens toward the town,

:::paragraphs{style="verse-in"}
And the tide rises, the tide falls.
:::

:::space

Darkness settles on roofs and walls,

But the sea in the darkness calls and calls;

The little waves, with their soft, white hands,

Efface the footprints in the sands,

:::paragraphs{style="verse-in"}
And the tide rises, the tide falls.
:::

:::space

The morning breaks; the steeds in their stalls

Stamp and neigh, as the hostler calls;

The day returns, but nevermore

Returns the traveller to the shore,

:::paragraphs{style="verse-in"}
And the tide rises, the tide falls.
:::
:::

:::space

## Requiem

:::paragraphs{style="verse"}
Under the wide and starry sky,

Dig the grave and let me lie.

Glad did I live and gladly die,

:::paragraphs{style="verse-in"}
And I laid me down with a will.
:::

:::space

This be the verse you grave for me:

*Here he lies where he longed to be*;

*Home is the sailor*, *home from sea*,

:::paragraphs{style="verse-in"}
*And the hunter home from the hill*.
:::
:::


:::pagebreak

## Crossing the Bar

:::paragraphs{style="verse"}
Sunset and evening star,

:::paragraphs{style="verse-in"}
And one clear call for me!
:::

And may there be no moaning of the bar,

:::paragraphs{style="verse-in"}
When I put out to sea,
:::

:::space

But such a tide as moving seems asleep,

:::paragraphs{style="verse-in"}
Too full for sound and foam,
:::

When that which drew from out the boundless deep

:::paragraphs{style="verse-in"}
Turns again home.
:::

:::space

Twilight and evening bell,

:::paragraphs{style="verse-in"}
And after that the dark!
:::

And may there be no sadness of farewell,

:::paragraphs{style="verse-in"}
When I embark;
:::

:::space

For tho’ from out our bourne of Time and Place

:::paragraphs{style="verse-in"}
The flood may bear me far,
:::

I hope to see my Pilot face to face

:::paragraphs{style="verse-in"}
When I have crost the bar.
:::
:::


# The performers {style="aside"}

**The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle.

:::paragraphs{style="colophon"}
Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined.
:::
`; // content.<lang>.md, inlined by the Cookbook

const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1"
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const row = (who, what, time, more) => [who, what, time].map((content, i) =>
  ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right
const resources = [
  { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' }, // where ::resource sets it, not floated to the foot
    table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [
      row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label
      row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'),
      row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'),
      row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'),
      row('Hester Vane', '*Requiem* · Stevenson', '3′10″'),
      row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'),
      row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'),
      row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }),
    ] } } },
  { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 },
    altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' },
];

// #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal
const WAVES = 115; // mm from the top edge: where the waves begin, under the venue
function coverArt(w, h, top) { // mm: the page, and where the waves begin
  const R = 12.5; // mm: the radius of one scale
  const f = (n) => +n.toFixed(2);
  const rows = Math.ceil((h - top) / (R / 2)) + 1;
  let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`;
  for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last
    const y = top + (i * R) / 2;
    const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot
    for (let x = (i % 2) * R; x <= w + R; x += 2 * R) {
      out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`;
      for (const k of [0.9, 0.64, 0.38]) {
        out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" `
          + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`;
      }
      out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" `
        + `fill-opacity="${glow}"/>`;
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" `
    + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>`
    + `<g clip-path="url(#page)">${out}</g></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Each bold and italic a block may ask for. Tenor Sans has 400 only (gotcha: faked-font-styles).
const FONTS = { 'Crimson Text': ['400', '400i', '600', '600i'], Fraunces: ['300', '300i'],
  'Tenor Sans': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the faces first, then the layout, then a check that nothing was missed
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });
// #endregion

// #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout; `base` marks a block's own face. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · 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 os marcadores de fora

Um folheto de uma página ou um cartaz não tem nada para marcar. Com `outlines: false`, o PDF fica sem marcadores e abre com a barra lateral fechada.

```diff
-    outlines: true, // the default, spelled out: each heading becomes a bookmark
+    outlines: false,
```

### Sirva você mesmo as fontes

Aponte o fetch para cópias suas dos mesmos arquivos WOFF2 estáticos, um por peso e estilo, com os nomes de arquivo do Fontsource. A página e o PDF continuam compartilhando cada download.

```diff
-    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+    files.set(file, fetch(`/fonts/${file}.woff2`)
       .then((res) => {
-        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+        if (!res.ok) throw new Error(`No font file ${file}.woff2`);
```

## Erros comuns

- **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 PDF pede todos os pesos e estilos de cada família.** renderToPdf pede ao provedor de fontes o negrito, o itálico e o negrito itálico de cada família que um bloco poderia usar, mesmo os que nunca são impressos, e uma única recusa interrompe a exportação. O provedor deve se ajustar ao peso mais próximo que a família oferece e voltar ao romano quando não houver itálico.
- **O negrito ou itálico que falta na família é simulado na tela, não no PDF.** Quando um texto pede um peso ou estilo que a família não traz (um cabeçalho de tabela em negrito numa fonte de títulos de um estilo só, itálico numa sem serifa sem itálicos), o navegador o sintetiza no canvas e no HTML, engrossando ou inclinando o redondo com as mesmas larguras. Um PDF só incorpora fontes reais, então nele sai a fonte mais próxima que o provedor entregar, sem engrossar nem inclinar. Ponha na sua lista de fontes só as que a família traz e ajuste cada estilo a elas, por exemplo com tableStyle.headerBold: false.
- **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.
- **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".
- **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 lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **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().

Liste em `FONTS` cada negrito e cada itálico que o seu texto pode usar. Se faltar um, a tela continua certa, porque o kit carrega a fonte sem avisar, mas o PDF imprime a fonte mais próxima que o provedor tiver, e só as substitutas na linha de status revelam a troca.

Use arquivos estáticos, um por peso e estilo. De um WOFF2 variável, o PDF incorpora só a instância padrão, e um trecho em negrito sairia com peso regular ([Por que um provedor de fontes?](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)).

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow: Henry Wadsworth Longfellow ([fonte](https://www.gutenberg.org/ebooks/1365)), domínio público
- Texto: “Requiem”, with the indents and italics of the first edition of Underwoods (1887): Robert Louis Stevenson ([fonte](https://www.gutenberg.org/ebooks/438)), domínio público
- Texto: “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription: Alfred Tennyson ([fonte](https://en.wikisource.org/wiki/Demeter_and_other_poems/Crossing_the_Bar)), domínio público
- Texto: The programme, the notes on the music, the performers and the colophon: Ignacio Ferro, CC-BY-4.0
- Imagens: The seigaiha waves on the cover, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Tipos: Crimson Text (OFL-1.1), Fraunces (OFL-1.1), Tenor Sans (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 040 · Fontes da marca na diagramação, no PDF e no pacote](https://postext.dev/pt/cookbook/brand-fonts-identity-manual.md): O manual de identidade de um metrô fictício composto com os arquivos de fonte da marca, baixados uma vez e usados na diagramação, no PDF e num pacote .postext. · Nível 3 (Avançado) · Manuais, guias e obras de referência
- [Nº 041 · Ida e volta de um .postext em duas línguas](https://postext.dev/pt/cookbook/bundle-round-trip.md): Um folheto DL de dois lados gravado num arquivo .postext pelo código e diagramado de novo a partir dos bytes, com as fontes, os desenhos e os rótulos da edição. · Nível 2 (Intermediário) · Folhas avulsas e impressos efêmeros
- [Nº 007 · Um livro feito de capítulos separados](https://postext.dev/pt/cookbook/book-from-chapters.md): buildBundle junta cinco arquivos Markdown num livro: cada capítulo abre em página ímpar, e páginas, capítulos e figuras seguem numerados de arquivo em arquivo. · Nível 3 (Avançado) · Manuais, guias e obras de referência
