# Ida e volta de um .postext em duas línguas

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

- Versão HTML: https://postext.dev/pt/cookbook/bundle-round-trip
- Receita Nº 041 · Saída e integração · Nível 2 (Intermediário) · Saídas: Canvas, PDF, Pacote .postext
- 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/bundle-round-trip/en/p01.webp?v=87ed4cb2), [2](https://postext.dev/cookbook/bundle-round-trip/en/p02.webp?v=87ed4cb2)
- PDF: https://postext.dev/cookbook/bundle-round-trip/en/bundle-round-trip.pdf?v=87ed4cb2
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=bundle-round-trip&lang=en (.postext: https://postext.dev/cookbook/bundle-round-trip/en/bundle-round-trip.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [en](https://postext.dev/en/cookbook/bundle-round-trip.md), [es](https://postext.dev/es/cookbook/bundle-round-trip.md), [ca](https://postext.dev/ca/cookbook/bundle-round-trip.md), [zh](https://postext.dev/zh/cookbook/bundle-round-trip.md), [ja](https://postext.dev/ja/cookbook/bundle-round-trip.md), [ar](https://postext.dev/ar/cookbook/bundle-round-trip.md)

## Em poucas palavras

Um folheto de dois lados sobre um velho moinho de maré, em inglês e espanhol. Mostra como salvar um documento com as fontes e imagens num só arquivo e depois tirar dele as mesmas páginas.

## O que você vai compor

Os dois lados de um folheto de visita no formato DL, 99 × 210 mm, para um moinho de maré num estuário inventado. A frente é um só desenho: uma roda de moinho na linha d’água, com a metade de cima sobre a areia e a de baixo clara sob a água azul, o título em DM Serif Display itálico de 50 pt e uma aba azul que nomeia a edição, EN ou ES. O verso abre com o moinho em corte e segue com o texto em DM Sans justificada, o horário numa tabela de cabeçalho azul, legendas em Instrument Sans, um colofão e uma faixa azul com a editora. Cada edição é gravada num arquivo `.postext`, e cada página aqui é diagramada a partir dos bytes desse arquivo, com as fontes e os desenhos que ele leva.

**Esta receita responde a:**

- Como faço para diagramar um arquivo .postext com as fontes, as imagens e a configuração que ele traz?
- Como crio um pacote .postext a partir do código, para passar um documento ao Sandbox ou a outro programa?
- Como publico o mesmo livro em dois idiomas a partir de um só projeto?
- Como faço os rótulos “Figura” e “Tabela” saírem no idioma do meu documento?

## A resposta curta

```js
// script.js, linhas 296–312
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
  name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
  locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
  markdown, config: config(), resources,
  files: { ...drawings, ...faceFiles },
  thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why

// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
```

## Ingredientes

**Ensina**

- [Pacotes .postext](https://postext.dev/pt/docs/configuration.md#pacotes-arquivos-postext): Um livro inteiro em um único arquivo (capítulos, configuração, recursos e fontes) que o código pode criar, abrir e diagramar e o Sandbox pode editar.
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso): defaultResourceTypes(locale) fornece os tipos nativos Figura e Tabela, com rótulos e referências no idioma do documento.
- [Suas próprias fontes](https://postext.dev/pt/docs/configuration.md#fontes-personalizadas): Famílias corporativas ou licenciadas declaradas em customFonts, registradas para o layout e incorporadas ao PDF a partir dos mesmos bytes.

**Também usa**

- [Hifenização e idioma do documento](https://postext.dev/pt/docs/justification.md#idiomas-compatíveis)
- [Legendas numeradas](https://postext.dev/pt/docs/document-format.md#numeração-pela-primeira-referência)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space)
- [Livros compostos capítulo a capítulo](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote)
- [Capas, folhas de rosto e colofões](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [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)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Metadados do documento](https://postext.dev/pt/docs/document-format.md#frontmatter)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Estilo de tabela](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)

**A configuração em resumo**

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

**API**

- [`buildBundle`](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote), [`bundleFontProvider`](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote), [`bundleResourceBytes`](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`createBundle`](https://postext.dev/pt/docs/configuration.md#criar-um-pacote), [`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), [`loadBundleFonts`](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote), [`openBundle`](https://postext.dev/pt/docs/configuration.md#abrir-um-pacote), [`registerBundleImages`](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote), [`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**

- DM Sans (OFL-1.1), DM Serif Display (OFL-1.1), Instrument Sans (OFL-1.1)

## Preparo

### 1 · Gravar a edição e ler de volta só os bytes

```js
// script.js, linhas 296–312
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
  name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
  locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
  markdown, config: config(), resources,
  files: { ...drawings, ...faceFiles },
  thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why

// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
```

`createBundle` compacta num zip o capítulo, a configuração, os recursos e todos os arquivos que eles nomeiam; `openBundle` não recebe nada além desses bytes. As fontes vêm de `loadBundleFonts`, os desenhos de `registerBundleImages` e o design de `bundle.config`. Uma fonte deixada fora de `files` sai na fonte reserva do navegador, e um desenho que fica de fora é descartado com um aviso, e a referência a ele imprime (?). Dentro do zip, cada `fileId` vira um caminho (`mill.svg` passa a ser `resources/mill.svg`), e os recursos e o `customFonts` que `openBundle` devolve já usam os nomes novos ([abrir um pacote](https://postext.dev/pt/docs/configuration.md#abrir-um-pacote)).

### 2 · Pôr as fontes no arquivo

```js
// script.js, linhas 279–291
const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name,
  variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2',
    style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })),
}));
// The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too.
const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) =>
  variants.map(async ({ weight, style, fileId }) => {
    const id = fontsourceId(name);
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
      + `${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${name} ${weight} ${style}`);
    return [fileId, new Uint8Array(await res.arrayBuffer())];
  }))));
```

Um pacote leva uma fonte quando `customFonts` a nomeia e `files` guarda os bytes dela sob o `fileId` da variante; `createBundle` grava a DM Sans 400 como `fonts/dm-sans-400-normal.woff2`. O pen baixa os sete arquivos woff2 da Fontsource, mas não registra nenhum: `loadBundleFonts` acrescenta as cópias do pacote a `document.fonts` antes de `buildBundle` medir uma linha, e o PDF pega as fontes desses mesmos sete arquivos.

### 3 · Nomear cada arquivo pelo fileId

```js
// script.js, linhas 236–269
const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra });
const row = (...cells) => cells.map((content) => ({ content }));
const head = (...cells) => cells.map((content) => ({ content, isHeader: true }));
const resources = [
  svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale '
    + 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad '
    + 'inferior pálida bajo la ría' })),
  svg('mill', SECTION.w, SECTION.h, t({
    en: 'The mill in section: the pond at high level on the left, the mill house on the dam '
      + 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the '
      + 'right below a dashed high-water line',
    es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre '
      + 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo '
      + 'una línea discontinua de pleamar' }), {
    placement: { position: 'here' },
    caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary '
      + 'has fallen below the dashed line.',
    es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo '
      + 'de la línea discontinua.' }) }),
  { id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' },
    caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.',
      es: 'Horario. Última entrada 45 minutos antes del cierre.' }),
    table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({
      en: [head('Season', 'Days', 'Hours'),
        row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'),
        row('July–August', 'Mon–Sun', '10:00–20:00'),
        row('September–March', 'Fri–Sun', '10:30–14:30')],
      es: [head('Temporada', 'Días', 'Horario'),
        row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'),
        row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'),
        row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } },
];
```

Os dois desenhos são marcação SVG gerada pelo pen e entregue a `files` sob o `svg.fileId` dos recursos deles; a tabela são dados, então viaja em `preset.json` com a legenda. O corte e a tabela são postos com `position: 'here'`: o corte no alto do verso, o horário sob o parágrafo que o cita. Na 1.4.1, um recurso na linha não ganha espaço embaixo, então uma linha `:::space{lines=0.5}` depois da tabela deixa 3,7 mm sob a legenda; sem ela, o parágrafo seguinte começa 1,4 mm abaixo da legenda.

### 4 · Gravar os rótulos no arquivo

```js
// script.js, linhas 56–61
  // Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales).
  locale: t({ en: 'en-us', es: 'es' }),
  // Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it:
  // the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale).
  // '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
```

Quando um arquivo não tem `resourceTypes`, `openBundle` os monta na língua pedida por quem abre, não na do arquivo: o folheto em espanhol aberto com `{ locale: 'en' }`, ou importado no Sandbox em /en/sandbox, imprime Figure 1.1 sobre texto em espanhol. Os tipos gravados na configuração substituem esse padrão, e assim o arquivo em espanhol imprime Figura 1 e Tabla 1 onde quer que seja aberto. `'{n}'` tira o número do capítulo, de que um folheto de duas páginas não precisa ([tipos de recurso](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)). O mesmo `config()` define `locale`, que escolhe os padrões de hifenização do espanhol (compuer-tas no verso) e a língua que o PDF declara.

### 5 · Repassar os mesmos bytes

```js
// script.js, linhas 318–326
const file = `tide-mill-${LANG}.postext`;
document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), {
  href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file,
  textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` }));
// The PDF embeds the faces the bundle carries, and draws the figures from its files.
offerPdf(() => renderToPdf(docs, {
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }),
  resourceBytes: bundleResourceBytes(bundle),
}), `${RECIPE}-${LANG}.pdf`);
```

O link oferece os `bytes` a partir dos quais as páginas foram diagramadas, 132 KB com as fontes. Importado no Sandbox (*Livros → Novo → Abrir um arquivo .postext…*), o arquivo vira um livro em espanhol ou inglês com a roda como imagem, tirada de `thumbnail`. O PDF pega as fontes de `bundleFontProvider`, que devolve a fonte do pacote mais próxima em peso, no mesmo estilo quando houver, e os desenhos de `bundleResourceBytes`, então nenhuma fonte ou imagem é baixada duas vezes ([diagramar e renderizar um pacote](https://postext.dev/pt/docs/configuration.md#diagramar-e-renderizar-um-pacote)).

### 6 · Desenhar a frente com um só título

```js
// script.js, linhas 28–51
const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } });
const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha:
  placement, ...look }); // overflow-ellipsis-default
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.15) };
const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts
const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page',
    edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
  text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps),
  // The language tab: the edition's code on a blue flap hanging from the top edge.
  text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' },
    offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: {
    backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2),
      left: mm(2.4) } } }),
  text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2),
    { italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
  text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4),
    { color: col('foam'), italic: true, lineHeight: 1.4 }),
  text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'),
    { ...caps, color: col('sand') }),
] } } };
```

A frente é o design do título de capa: o desenho ancorado à página em tamanho total, o texto do título por `{titleText}`, o chapéu, o olho e a linha de informações vindos dos atributos dele, e um elemento de texto com caixa para a aba da língua. O nível H1 tem `span: 'page'`, então o desenho começa na borda do papel. Um design de título mantido na coluna é cortado nas bordas superior e inferior da mancha: o desenho começaria 12 mm mais abaixo e pararia 13 mm acima da borda inferior, e a aba da língua encolheria até virar uma tira sem letras.

## 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/bundle-round-trip

### script.js

```js
// ═══ Postext Cookbook · Nº 041 · .postext round trip in two languages ════════════
// https://postext.dev/en/cookbook/bundle-round-trip
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: DM Sans, DM Serif Display, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { createBundle, openBundle, loadBundleFonts, registerBundleImages, buildBundle,
  bundleFontProvider, bundleResourceBytes, defaultResourceTypes, renderPageToCanvas,
  clearMeasurementCache } 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 = 'bundle-round-trip';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#172130', muted: '#56606c', // text; the colophon
  estuary: '#25476a', mud: '#8a6f4d', // the one accent; the wheel's wood in the drawings
  sand: '#e9dcc4', foam: '#eef2f3', rule: '#c4ced6', paper: '#ffffff' };
// The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the estuary blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.estuary })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['DM Sans', 'DM Serif Display', 'Instrument Sans'];
const PAGE = { w: 99, h: 210, top: 12, bottom: 13, side: 10 }; // mm: a DL leaflet, both sides
const [BODY, LEAD] = [9.4, 13.4]; // pt
const WATER = 98; // mm from the top of the cover: where the sand ends and the estuary begins

// #region cover: the front of the leaflet, a heading drawn over one picture
const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } });
const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha:
  placement, ...look }); // overflow-ellipsis-default
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.15) };
const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts
const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page',
    edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
  text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps),
  // The language tab: the edition's code on a blue flap hanging from the top edge.
  text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' },
    offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: {
    backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2),
      left: mm(2.4) } } }),
  text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2),
    { italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
  text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4),
    { color: col('foam'), italic: true, lineHeight: 1.4 }),
  text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'),
    { ...caps, color: col('sand') }),
] } } };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  // #region labels: the edition's language, written into the file with the rest of the config
  // Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales).
  locale: t({ en: 'en-us', es: 'es' }),
  // Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it:
  // the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale).
  // '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
  // #endregion
  colorPalette, customFonts,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.side),
      right: mm(PAGE.side) } }, // a flyer printed both sides: nothing to mirror
  layout: { layoutType: 'single', inlineResourceGap: 'above' }, // no line under the figures
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false, minWordSpacing: 0.75, maxWordSpacing: 1.6 },
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // in main-color: the estuary
    // The H1 break, restated (gotcha: headings-drop-h1-break). In the column, the cover design
    // is cut at the text block's top and bottom edges; span: 'page' paints it from the trim.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(15), lineHeight: pt(LEAD * 1.25), marginTop: pt(LEAD * 0.5),
      marginBottom: pt(LEAD * 0.25) },
  ] },
  headingStyles: [cover],
  captionStyle: { fontFamily: LABEL, fontSize: pt(7.8), labelColor: col('estuary'), gap: mm(1.8) },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('estuary'), headerColor: col('paper'), headerFontFamily: LABEL,
    headerFontSize: pt(7.6), bodyFontSize: pt(8.2), cellPadding: mm(1.3) },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(6.6), lineHeight: pt(8.8),
    color: col('muted'), textAlign: 'left', firstLineIndent: mm(0), marginTop: pt(LEAD) }],
  header: { elements: [] },
  // The back's foot: a strip of estuary with the publisher, the frontmatter's author.
  footer: { elements: [
    { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('estuary') },
      placement: { anchor: { to: 'page', edge: 'bottom-left' },
        size: { width: 'fill', height: mm(7) } } },
    text('foot', '{author}', LABEL, 7.5, { anchor: { to: 'page', edge: 'bottom-left' },
      offset: { x: mm(PAGE.side), y: mm(-2.4) } }, { ...caps, color: col('foam'),
      pages: 'body', overflow: 'clip' }),
  ] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Tide Mill of Arenal"
author: "Arenal Estuary Trust"
---

# The Tide Mill {style="cover" kicker="Arenal estuary · Visitor leaflet 3" lead="For 165 years the tide turned its four wheels. Walk the dam and look down into the wheel pit, where the pond empties twice a day." facts="Open all year · Free on Sundays" edition="EN"}

:::pagebreak

::resource{id="mill"}

## Two tides a day

On the flood tide the sea pushes open the gates in the dam and fills the millpond behind it, six hectares of salt water; when the tide turns, the water inside presses them shut. Two hours after high water, the estuary has fallen far enough for the miller to open the chutes (:ref{id="mill" style="full"}). The water drops onto a horizontal wheel in the vaulted pit under the floor, and an upright shaft turns the millstones above it.

The mill ground maize and wheat for the farms of the valley from 1791 until 1956. Restored in 2004, it grinds again on demonstration days, two hours after high water (:ref{id="hours" style="full"}).

::resource{id="hours"}

:::space{lines=0.5}

The path on the dam is flat enough for wheelchairs, and eleven steps lead down to the wheel pit. Tickets cost €3; entry is free on Sundays.

:::paragraphs{style="colophon"}
Leaflet 3, English edition · Text and drawings CC BY 4.0 · Set in DM Sans, DM Serif Display and Instrument Sans (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the cover's wheel in the estuary, and the mill in section
// No words in the drawings: an SVG drawn as an image cannot use web fonts (gotcha:
// svg-no-webfonts). Every length is in millimetres of the printed page.
const SECTION = { w: 79, h: 35 }; // the mill in section, as wide as the text
const n = (v) => +v.toFixed(2);
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const line = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`);
const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${body}</g>`;
// A wave line across the page: cubic arcs of wavelength `len`, `amp` high.
const wave = (y, len, amp, phase, width) => {
  let d = `M${n(-phase)} ${n(y)}`;
  for (let x = -phase; x < width + len; x += len) {
    const [q, h] = [x + len / 4, x + 3 * len / 4];
    d += `C${n(q)} ${n(y - amp)} ${n(q)} ${n(y - amp)} ${n(x + len / 2)} ${n(y)}`
      + `C${n(h)} ${n(y + amp)} ${n(h)} ${n(y + amp)} ${n(x + len)} ${n(y)}`;
  }
  return d;
};
// The wheel: a hub and eighteen blades, each a spoon on a spoke, the spoon bent back against
// the turn; the square end of the shaft at the centre.
function wheel(cx, cy, r, color, extra = '') {
  const spoke = `M${n(r * 0.28)} ${n(-r * 0.018)}H${n(r * 0.54)}V${n(r * 0.018)}H${n(r * 0.28)}Z`;
  const spoon = `M0 0C${n(r * 0.1)} ${n(-r * 0.08)} ${n(r * 0.36)} ${n(-r * 0.12)} ${n(r * 0.46)} `
    + `${n(-r * 0.05)}C${n(r * 0.5)} ${n(-r * 0.01)} ${n(r * 0.44)} ${n(r * 0.06)} ${n(r * 0.3)} `
    + `${n(r * 0.06)}C${n(r * 0.18)} ${n(r * 0.06)} ${n(r * 0.06)} ${n(r * 0.03)} 0 0Z`;
  const blade = path(spoke, color) + group(r * 0.52, 0, -16, path(spoon, color));
  let out = '';
  for (let i = 0; i < 18; i++) out += group(cx, cy, i * 20, blade);
  const ring = ` stroke="${palette.sand}" stroke-width="${n(r * 0.03)}"`;
  return `<g${extra}>${out}${circle(cx, cy, r * 0.31, color)}`
    + `${circle(cx, cy, r * 0.22, 'none', ring)}`
    + `<rect x="${n(cx - r * 0.06)}" y="${n(cy - r * 0.06)}" width="${n(r * 0.12)}" `
    + `height="${n(r * 0.12)}" fill="${palette.sand}"/></g>`;
}
function coverArt() {
  const [cx, r] = [PAGE.w / 2, 37];
  let body = `<rect width="${PAGE.w}" height="${WATER}" fill="${palette.sand}"/>`;
  // The mud flat the ebb leaves: three bands above the waterline, darker towards the water.
  for (const [y, h, o] of [[WATER - 15, 3, 0.1], [WATER - 10, 4, 0.16], [WATER - 5, 5, 0.24]]) {
    body += `<rect y="${y}" width="${PAGE.w}" height="${h}" fill="${palette.mud}" `
      + `fill-opacity="${o}"/>`;
  }
  body += wheel(cx, WATER, r, palette.estuary);
  body += `<rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h - WATER}" `
    + `fill="${palette.estuary}"/>`;
  // Under the water the wheel shows as a pale ghost: the same drawing, clipped to the water.
  body += `<clipPath id="under"><rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h}"/>`
    + `</clipPath>${wheel(cx, WATER, r, palette.foam, ' clip-path="url(#under)" opacity=".2"')}`;
  for (const [dy, phase, o] of [[3, 0, 0.5], [10, 4, 0.3], [18, 8, 0.2], [28, 2, 0.12]]) {
    body += line(wave(WATER + dy, 11, 0.9, phase, PAGE.w), palette.foam, 0.7,
      ` stroke-opacity="${o}"`);
  }
  return svgDoc(PAGE.w, PAGE.h, body);
}
// A level mark: the surveyor's triangle standing on a water surface.
const level = (x, y, fill) => path(`M${n(x - 1.4)} ${n(y - 2.2)}H${n(x + 1.4)}L${n(x)} ${n(y)}Z`,
  fill, fill === 'none' ? ` stroke="${palette.estuary}" stroke-width=".3"` : '');
const arrow = (d, tip, turn, color) => line(d, color, 0.55)
  + group(...tip, turn, line('M-1.6-1L0 0-1.6 1', color, 0.55));
function sectionArt() {
  const { w, h } = SECTION;
  const [HIGH, LOW, FLOOR, WHEEL] = [10, 26.5, 15.5, 27]; // mm: levels, floor and wheel heights
  const P = palette;
  let b = '';
  // Water first: the pond held at high tide, the estuary fallen to low water.
  b += path(`M0 ${HIGH}H31V33H0Z`, P.estuary);
  b += path(`M52 ${LOW}H${w}V${h}H52Z`, P.estuary);
  b += line(`M52 ${HIGH}H${w - 1}`, P.estuary, 0.35, ' stroke-dasharray="1.4 1"');
  // The ground: the pond's bed and the estuary's mud bank.
  b += path(`M0 33L31 32V${h}H0Z`, P.mud);
  b += path(`M52 32.5L${w} 34V${h}H52Z`, P.mud);
  // The dam and the mill house on it, in sand with a mud outline; the roof in mud.
  const stroke = ` stroke="${P.mud}" stroke-width=".45"`;
  b += path(`M30 ${h}V5.6H54V${h}Z`, P.sand, stroke);
  b += path('M28.5 6L42 0.4L55.5 6Z', P.mud);
  // The wheel pit: a vaulted opening through the dam, with the ebb running out of it.
  b += path(`M33.5 ${h}V25A8 8 0 0 1 49.5 25V${h}Z`, P.paper, stroke);
  b += path('M33.5 30.5H55V33.5H33.5Z', P.estuary);
  // The chute from the pond onto the wheel, and the gate lifted above its mouth.
  b += path(`M30 22L36.4 ${WHEEL - 1.2}`, 'none', ` stroke="${P.estuary}" stroke-width="1.8"`);
  b += `<rect x="29.2" y="16.8" width="1.6" height="4" fill="${P.ink}"/>`;
  // The horizontal wheel, and its shaft up through the floor to the runner stone.
  b += line(`M41.5 ${FLOOR}V${WHEEL + 1}`, P.ink, 0.6);
  b += `<rect x="35.8" y="${WHEEL - 0.8}" width="11.4" height="1.6" rx=".5" fill="${P.mud}"/>`;
  for (let x = 36.6; x < 47; x += 1.6) {
    b += line(`M${n(x)} ${WHEEL - 1.4}V${WHEEL + 1.2}`, P.mud, 0.45); // the blades, edge-on
  }
  // The milling floor, the runner stone on the bed stone, and the hopper above them.
  b += line(`M31 ${FLOOR}H53`, P.mud, 0.45);
  const stone = (x, y, sw) => `<rect x="${x}" y="${n(y)}" width="${sw}" height="1.6" `
    + `fill="${P.rule}" stroke="${P.ink}" stroke-width=".3"/>`;
  b += stone(36.5, FLOOR - 3.2, 10) + stone(36, FLOOR - 1.6, 11);
  b += path(`M38.6 8H44.4L42.6 ${FLOOR - 4.2}H40.4Z`, P.mud);
  // Level marks, and the way the water goes.
  b += level(6, HIGH, P.estuary) + level(73, LOW, P.estuary) + level(73, HIGH, 'none');
  b += arrow('M9 27C16 26 22 24.4 27.4 23', [27.4, 23], -15, P.foam);
  b += arrow('M50.5 32H63', [63, 32], 0, P.foam);
  return svgDoc(w, h, b);
}
// fileId → markup: the files the resources below name.
const drawings = { 'cover.svg': coverArt(), 'mill.svg': sectionArt() };
// #endregion

// #region resources: the drawings name their files by fileId; the table carries its own data
const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra });
const row = (...cells) => cells.map((content) => ({ content }));
const head = (...cells) => cells.map((content) => ({ content, isHeader: true }));
const resources = [
  svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale '
    + 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad '
    + 'inferior pálida bajo la ría' })),
  svg('mill', SECTION.w, SECTION.h, t({
    en: 'The mill in section: the pond at high level on the left, the mill house on the dam '
      + 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the '
      + 'right below a dashed high-water line',
    es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre '
      + 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo '
      + 'una línea discontinua de pleamar' }), {
    placement: { position: 'here' },
    caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary '
      + 'has fallen below the dashed line.',
    es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo '
      + 'de la línea discontinua.' }) }),
  { id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' },
    caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.',
      es: 'Horario. Última entrada 45 minutos antes del cierre.' }),
    table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({
      en: [head('Season', 'Days', 'Hours'),
        row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'),
        row('July–August', 'Mon–Sun', '10:00–20:00'),
        row('September–March', 'Fri–Sun', '10:30–14:30')],
      es: [head('Temporada', 'Días', 'Horario'),
        row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'),
        row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'),
        row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } },
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages use. They travel inside the bundle, so the reader loads them from
// there, before the layout (gotcha: fonts-first).
const FONTS = { 'DM Sans': ['400', '400i', '700'], 'DM Serif Display': ['400', '400i'],
  'Instrument Sans': ['400', '700'] };

// #region faces: FONTS as customFonts, each face a woff2 file named by its fileId
const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name,
  variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2',
    style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })),
}));
// The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too.
const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) =>
  variants.map(async ({ weight, style, fileId }) => {
    const id = fontsourceId(name);
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
      + `${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${name} ${weight} ${style}`);
    return [fileId, new Uint8Array(await res.arrayBuffer())];
  }))));
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region answer: write this edition to a .postext file, then lay it out from those bytes alone
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
  name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
  locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
  markdown, config: config(), resources,
  files: { ...drawings, ...faceFiles },
  thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why

// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
// #endregion
showPages(docs, { title: t({ en: 'The Tide Mill · English edition',
  es: 'El molino de mareas · edición en español' }) });

// #region handoff: the same bytes as a download for the Sandbox, and a PDF from the bundle
const file = `tide-mill-${LANG}.postext`;
document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), {
  href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file,
  textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` }));
// The PDF embeds the faces the bundle carries, and draws the figures from its files.
offerPdf(() => renderToPdf(docs, {
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }),
  resourceBytes: bundleResourceBytes(bundle),
}), `${RECIPE}-${LANG}.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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Ler um pacote bilíngue

O guia do Postext leva num só arquivo os capítulos, a configuração e as legendas em inglês e espanhol; `{ locale }` escolhe a edição, que abre como doze capítulos em 48 páginas em qualquer das duas línguas, enquanto o link continua oferecendo o arquivo do folheto e o botão de PDF compõe o guia.

```diff
-const bundle = await openBundle(bytes);
+const guide = await fetch('https://postext.dev/bundles/postext-guide.postext');
+const bundle = await openBundle(await guide.arrayBuffer(), { locale: LANG });
```

### Deixar o leitor escolher os rótulos

Sem os tipos, os rótulos seguem a língua em que o arquivo é aberto, e as figuras contam a partir do título de capa: a edição espanhola imprime aqui Figura 1.1, e Figure 1.1 quando o Sandbox em /en/sandbox a importa.

```diff
-  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
```

## 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.
- **Um pacote sem resourceTypes rotula as figuras no idioma de quem o abre.** Quando um arquivo .postext não traz resourceTypes, openBundle no postext 1.4.1 cria Figura e Tabela no idioma que quem o abre pede, não no do próprio arquivo, e o Sandbox importa cada arquivo no idioma da sua interface. Um pacote em espanhol aberto com { locale: 'en' }, ou importado em /en/sandbox, imprime Figure 1.1 sobre texto em espanhol enquanto bundle.locale continua dizendo 'es'. Escreva resourceTypes: defaultResourceTypes(lang) na configuração que você passa para createBundle.
- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **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.
- **Uma figura no texto ganha espaço acima, mas não abaixo.** No postext 1.4.1, uma figura que ::resource coloca na posição 'here' ganha uma linha da grade de espaço acima, mas abaixo só o que sobra quando a linha seguinte se ajusta à grade de linhas de base: de uma linha inteira a quase nada, então o parágrafo seguinte pode começar colado à legenda. Ponha :::space{lines=1} depois da linha ::resource; como todo :::space, ele é descartado no alto de uma coluna.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **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().

- No postext 1.4.1, `createBundle` grava uma língua por arquivo. A entrada `localized`, descrita em [Pacotes bilíngues](https://postext.dev/pt/docs/configuration.md#pacotes-bilíngues), não existe nessa versão, embora o `openBundle` 1.4.1 leia esses arquivos.

## Créditos

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

## Relacionadas

- [Nº 011 · Um só original, edições impressa e de tela](https://postext.dev/pt/cookbook/print-and-screen-editions.md): As duas edições saem de uma configuração: htmlViewer.overrides guarda o design escuro de tela e applyHtmlViewerOverrides o mescla antes de cada composição HTML. · Nível 3 (Avançado) · Revistas e fanzines
- [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º 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
