# Anada i tornada d'un .postext en dues llengües

> Un fulletó DL a doble cara desat en un fitxer .postext i compost de nou a partir dels seus bytes, amb les fonts, els dibuixos i les etiquetes que porta.

- Versió HTML: https://postext.dev/ca/cookbook/bundle-round-trip
- Recepta Núm. 041 · Sortida i integració · Nivell 2 (Intermedi) · Sortides: Canvas, PDF, Paquet .postext
- Gèneres: Fulls solts i efímers
- Requereix postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · provada amb 1.4.1, postext-pdf 1.4.1 el 2026-09-26
- Pàgines: [1](https://postext.dev/cookbook/bundle-round-trip/es/p01.webp?v=2262a441), [2](https://postext.dev/cookbook/bundle-round-trip/es/p02.webp?v=2262a441)
- PDF: https://postext.dev/cookbook/bundle-round-trip/es/bundle-round-trip.pdf?v=2262a441
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=bundle-round-trip&lang=es (.postext: https://postext.dev/cookbook/bundle-round-trip/es/bundle-round-trip.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/bundle-round-trip.md), [es](https://postext.dev/es/cookbook/bundle-round-trip.md), [zh](https://postext.dev/zh/cookbook/bundle-round-trip.md), [ar](https://postext.dev/ar/cookbook/bundle-round-trip.md)

## En poques paraules

Un fulletó de dues cares d'un antic molí de marea, en anglès i en castellà. Ensenya a desar un document amb les seves fonts i imatges en un sol fitxer i a treure'n les mateixes pàgines.

## Què compondràs

Les dues cares d'un fulletó DL de visita, de 99 × 210 mm, per a un molí de marea en una ria inventada. La portada és un sol dibuix: una roda de molí a la línia de l'aigua, amb la meitat superior sobre la sorra i la inferior pàl·lida sota el blau de l'aigua, el títol en DM Serif Display cursiva de 50 pt i una pestanya blava amb el codi de l'edició, ES o EN. El revers comença amb el molí en secció i continua amb el text en DM Sans justificada, l'horari en una taula amb la capçalera en blau, peus en Instrument Sans, un colofó i una franja blava amb la fundació que l'edita. Cada edició s'escriu en un fitxer `.postext`, i cada pàgina que veus es compon a partir dels bytes d'aquest fitxer, amb les fonts i els dibuixos que porta a dins.

**Aquesta recepta respon a:**

- Com componc un fitxer .postext amb les fonts, les imatges i la configuració que porta a dins?
- Com creo un paquet .postext des del codi per passar un document al Sandbox o a un altre programa?
- Com publico el mateix llibre en dues llengües des d'un sol projecte?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?

## La resposta curta

```js
// script.js, línies 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
```

## Ingredients

**Ensenya**

- [Paquets .postext](https://postext.dev/ca/docs/configuration.md#paquets-fitxers-postext): Un llibre sencer en un fitxer (capítols, configuració, recursos i fonts) que el codi pot crear, obrir i compondre, i el Sandbox pot editar.
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs): defaultResourceTypes(locale) retorna els tipus Figura i Taula de sèrie, amb etiquetes i referències en la llengua del document.
- [Les teves pròpies fonts](https://postext.dev/ca/docs/configuration.md#fonts-personalitzades): Famílies corporatives o amb llicència declarades a customFonts, registrades per a la composició i incrustades al PDF amb els mateixos bytes.

**També fa servir**

- [Partició de mots i llengua del document](https://postext.dev/ca/docs/justification.md#llengües-compatibles)
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)
- [Llibres construïts capítol a capítol](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Metadades del document](https://postext.dev/ca/docs/document-format.md#frontmatter)
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Estil de taules](https://postext.dev/ca/docs/configuration.md#estil-de-taules)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- citations
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`customFonts`](https://postext.dev/ca/docs/configuration.md#fonts-personalitzades), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules)

**API**

- [`buildBundle`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`bundleFontProvider`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`bundleResourceBytes`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`createBundle`](https://postext.dev/ca/docs/configuration.md#crear-un-paquet), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`loadBundleFonts`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`openBundle`](https://postext.dev/ca/docs/configuration.md#obrir-un-paquet), [`registerBundleImages`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

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

## Elaboració

### 1 · Escriu l'edició i torna a llegir només els bytes

```js
// script.js, línies 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` comprimeix en un zip el capítol, la configuració, els recursos i tots els fitxers que anomenen, i `openBundle` no rep res més que aquests bytes. Les fonts surten de `loadBundleFonts`, els dibuixos de `registerBundleImages` i el disseny de `bundle.config`. Una font que no sigui a `files` surt amb la font de reserva del navegador, i un dibuix que falti es descarta amb un avís i la seva remissió s'imprimeix com a (?). Dins el zip, cada `fileId` passa a ser una ruta (`mill.svg` ara és `resources/mill.svg`), i els recursos i el `customFonts` que retorna `openBundle` ja fan servir els noms nous ([obrir un paquet](/ca/docs/configuration#obrir-un-paquet)).

### 2 · Posa les fonts dins el fitxer

```js
// script.js, línies 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())];
  }))));
```

Un paquet porta una font quan `customFonts` l'anomena i `files` en desa els bytes amb el `fileId` de la variant; `createBundle` desa la DM Sans 400 com a `fonts/dm-sans-400-normal.woff2`. El pen baixa de Fontsource els set woff2, però no en registra cap: `loadBundleFonts` afegeix a `document.fonts` les còpies del paquet abans que `buildBundle` mesuri una sola línia, i el PDF treu les seves fonts d'aquests mateixos set fitxers.

### 3 · Anomena cada fitxer pel seu fileId

```js
// script.js, línies 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')] }) } } },
];
```

Els dos dibuixos són SVG que genera el mateix pen i que es lliuren a `files` amb el `svg.fileId` del seu recurs; la taula són dades, així que viatja a `preset.json` amb el seu peu. La secció i la taula es col·loquen amb `position: 'here'`: la secció al capdamunt del revers i l'horari sota el paràgraf que el cita. A la versió 1.4.1, un recurs en línia no porta aire a sota, així que una línia `:::space{lines=0.5}` després de la taula deixa 3,7 mm sota el peu; sense aquesta línia, el paràgraf següent comença a 1,4 mm del peu.

### 4 · Escriu les etiquetes al fitxer

```js
// script.js, línies 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}' })),
```

Quan un fitxer no porta `resourceTypes`, `openBundle` els crea en la llengua que demana qui l'obre, no en la del fitxer: el fulletó en castellà obert amb `{ locale: 'en' }`, o importat al Sandbox des de /en/sandbox, imprimeix Figure 1.1 sobre text en castellà. Els tipus escrits a la configuració substitueixen aquests, així que el fitxer en castellà imprimeix Figura 1 i Tabla 1 l'obri qui l'obri. `'{n}'` treu el número de capítol, que un fulletó de dues pàgines no necessita ([tipus de recurs](/ca/docs/configuration#tipus-de-recurs)). La mateixa funció `config()` fixa `locale`, que tria els patrons de partició de mots del castellà (compuer-tas al revers) i la llengua que declara el PDF.

### 5 · Lliura els mateixos bytes

```js
// script.js, línies 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`);
```

L'enllaç ofereix els mateixos `bytes` des dels quals es van compondre les pàgines: 132 KB amb les fonts. En importar-lo al Sandbox (*Llibres → Nou → Obrir un fitxer .postext…*), el fitxer es converteix en un llibre en castellà o en anglès que porta com a imatge la roda de `thumbnail`. El PDF pren les seves fonts de `bundleFontProvider`, que retorna la del paquet més propera en pes, amb el mateix estil si n'hi ha, i els seus dibuixos de `bundleResourceBytes`, així que no es baixa dues vegades cap font ni cap imatge ([compondre i renderitzar un paquet](/ca/docs/configuration#compondre-i-renderitzar-un-paquet)).

### 6 · Dibuixa la portada amb un sol títol

```js
// script.js, línies 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') }),
] } } };
```

La portada és el disseny del títol de coberta: el dibuix ancorat a la pàgina a mida completa, el text del títol amb `{titleText}`, l'avanttítol, l'entradeta i la línia de dades, que surten dels seus atributs, i un text amb caixa per a la pestanya de la llengua. El nivell 1 dels títols porta `span: 'page'`, així que el dibuix comença a la vora del paper. Si es queda a la columna, el disseny es talla a les vores superior i inferior de la caixa de text: el dibuix començaria 12 mm més avall i acabaria 13 mm abans de la vora inferior, i la pestanya de la llengua quedaria en una tira sense lletres.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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 = 'es'; // @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' },
  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: "El molino de mareas de Arenal"
author: "Fundación Ría de Arenal"
---

# El molino de mareas {style="cover" kicker="Ría de Arenal · Folleto de visita 3" lead="Durante 165 años, la marea movió sus cuatro rodeznos. Recorre la presa y asómate al cárcavo, por donde se vacía el estanque dos veces al día." facts="Abierto todo el año · Gratis los domingos" edition="ES"}

:::pagebreak

::resource{id="mill"}

## Dos mareas al día

Cuando la marea sube, el mar empuja las compuertas de la presa y llena el estanque, seis hectáreas de agua salada; cuando baja, el agua del estanque las cierra. Pasadas dos horas de la pleamar, la ría está lo bastante baja para que el molinero abra los saetines (:ref{id="mill" style="full" case="lower"}). El agua cae sobre un rodezno, una rueda horizontal que gira en el cárcavo, bajo el suelo, y un eje vertical mueve las muelas de arriba.

El molino hizo harina de maíz y trigo para el valle desde 1791 hasta 1956. Restaurado en 2004, vuelve a moler los días de demostración, dos horas tras la pleamar (:ref{id="hours" style="full" case="lower"}).

::resource{id="hours"}

:::space{lines=0.5}

El camino de la presa es llano y apto para sillas de ruedas, y al cárcavo se baja por once peldaños. La entrada cuesta 3 euros y es gratuita los domingos.

:::paragraphs{style="colophon"}
Folleto 3, edición en español · Texto y dibujos CC BY 4.0 · Compuesto en DM Sans, DM Serif Display e 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      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` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs 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; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
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' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
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 v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Llegeix un paquet bilingüe

La guia de Postext porta en un sol fitxer els seus capítols, la seva configuració i els seus peus en anglès i en castellà; `{ locale }` tria l'edició, que s'obre com a dotze capítols en 48 pàgines en qualsevol de les dues llengües, mentre l'enllaç continua oferint el fitxer del fulletó i el botó del PDF compon la 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 });
```

### Deixa que el lector triï les etiquetes

Sense els tipus, les etiquetes segueixen la llengua amb què s'obre el fitxer i les figures compten des del títol de coberta: l'edició castellana imprimeix aquí Figura 1.1, i Figure 1.1 quan la importa el Sandbox des de /en/sandbox.

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

## Errors freqüents

- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Un paquet sense resourceTypes retola les figures en l'idioma de qui l'obre.** Quan un fitxer .postext no porta resourceTypes, openBundle a postext 1.4.1 crea Figura i Taula en l'idioma que demana qui l'obre, no en el del fitxer, i el Sandbox importa cada fitxer en l'idioma de la seva interfície. Un paquet en castellà obert amb { locale: 'en' }, o importat des de /en/sandbox, imprimeix Figure 1.1 sobre text en castellà encara que bundle.locale continuï dient 'es'. Escriu resourceTypes: defaultResourceTypes(idioma) a la configuració que passes a createBundle.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una figura en línia porta aire a sobre, però no a sota.** A postext 1.4.1, una figura que ::resource col·loca amb la posició 'here' porta una línia de la retícula de base d'aire a sobre, però a sota només el que sobra quan la línia següent s'ajusta a la retícula: des d'una línia sencera fins a gairebé res, de manera que el paràgraf següent pot començar enganxat al peu. Posa :::space{lines=1} després de la línia ::resource; com tot :::space, es descarta al capdamunt d'una columna.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().

- A postext 1.4.1, `createBundle` escriu una sola llengua per fitxer. L'entrada `localized` que descriu [Paquets bilingües](/ca/docs/configuration#paquets-bilingües) no és en aquesta versió, tot i que `openBundle` 1.4.1 ja llegeix aquests fitxers.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: DM Sans (OFL-1.1), DM Serif Display (OFL-1.1), Instrument Sans (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 011 · Un sol original, edicions impresa i de pantalla](https://postext.dev/ca/cookbook/print-and-screen-editions.md): Les dues edicions surten d'una configuració: htmlViewer.overrides desa el disseny fosc i applyHtmlViewerOverrides el fusiona abans de compondre l'HTML. · Nivell 3 (Avançat) · Revistes i fanzins
- [Núm. 025 · Un PDF de debò amb les mateixes fonts incrustades](https://postext.dev/ca/cookbook/pdf-with-embedded-fonts.md): Un programa de mà exportat a PDF. Cada font es baixa un sol cop, per a FontFace i per al PDF, i cada títol esdevé un marcador. · Nivell 2 (Intermedi) · Fulls solts i efímers
- [Núm. 007 · Un llibre fet de capítols solts](https://postext.dev/ca/cookbook/book-from-chapters.md): buildBundle compon cinc fitxers Markdown com un sol llibre: cada capítol obre en pàgina senar i pàgines, capítols i figures es numeren correlativament. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
