# .postext hin und zurück in zwei Sprachen

> Ein beidseitiges DL-Faltblatt, im Code als .postext-Datei gespeichert und aus deren Bytes neu gesetzt, mit Schriften, Zeichnungen und Legenden je Ausgabe.

- HTML-Fassung: https://postext.dev/de/cookbook/bundle-round-trip
- Rezept Nº 041 · Ausgabe und Integration · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF, .postext-Paket
- Gattungen: Einzelblätter und Akzidenzen
- Benötigt postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/bundle-round-trip/en/p01.webp?v=324ce67b), [2](https://postext.dev/cookbook/bundle-round-trip/en/p02.webp?v=324ce67b)
- PDF: https://postext.dev/cookbook/bundle-round-trip/en/bundle-round-trip.pdf?v=324ce67b
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=bundle-round-trip&lang=en (.postext: https://postext.dev/cookbook/bundle-round-trip/en/bundle-round-trip.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [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), [fr](https://postext.dev/fr/cookbook/bundle-round-trip.md), [it](https://postext.dev/it/cookbook/bundle-round-trip.md), [pt](https://postext.dev/pt/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)

## Kurz gesagt

Ein beidseitiges Faltblatt für eine alte Gezeitenmühle, auf Englisch und Spanisch. Es zeigt, wie man ein Dokument mit Schriften und Bildern in einer Datei speichert und aus dieser Datei dieselben Seiten zurückbekommt.

## Was Sie setzen

Beide Seiten eines Besucherfaltblatts im Format DL, 99 × 210 mm, für eine Gezeitenmühle an einer erfundenen Flussmündung. Die Vorderseite ist eine einzige Zeichnung: ein Mühlrad auf der Wasserlinie, seine obere Hälfte auf dem Sand, die untere blass unter dem blauen Wasser, dazu der Titel in der DM Serif Display Italic zu 50 pt und ein blauer Reiter, der die Ausgabe nennt, EN oder ES. Die Rückseite beginnt mit der Mühle im Schnitt, dann folgen der Text in DM Sans im Blocksatz, die Öffnungszeiten in einer Tabelle mit blauem Kopf, Legenden in Instrument Sans, ein Impressum und ein blauer Streifen mit dem Herausgeber. Jede Ausgabe wird in eine `.postext`-Datei geschrieben, und jede Seite hier ist aus den Bytes dieser Datei gesetzt, mit den Schriften und Zeichnungen, die sie mitbringt.

**Dieses Rezept beantwortet:**

- Wie setze ich eine .postext-Datei mit den Schriften, Bildern und der Konfiguration, die sie mitbringt?
- Wie erzeuge ich ein .postext-Paket im Code, um ein Dokument an die Sandbox oder ein anderes Programm zu übergeben?
- Wie veröffentliche ich dasselbe Buch aus einem Projekt in zwei Sprachen?
- Wie erhalte ich die Beschriftungen „Abbildung“ und „Tabelle“ in der Sprache meines Dokuments?

## Die kurze Antwort

```js
// script.js, Zeilen 294–310
// 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 file; `localized` would add the other edition to it
  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
```

## Zutaten

**Zeigt**

- [.postext-Pakete](https://postext.dev/de/docs/configuration-programmatic-usage.md#pakete-dateien-im-format-postext): Ein ganzes Buch in einer Datei (Kapitel, Konfiguration, Ressourcen und Schriften), das Code erzeugen, öffnen und setzen und die Sandbox bearbeiten kann.
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen): defaultResourceTypes(locale) liefert die eingebauten Typen Abbildung und Tabelle, ihre Bezeichnungen und Verweise in der Sprache des Dokuments.
- [Eigene Schriften](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#eigene-schriften): Haus- oder lizenzierte Schriftfamilien, in customFonts deklariert, für das Layout registriert und aus denselben Bytes in das PDF eingebettet.

**Verwendet außerdem**

- [Silbentrennung und Dokumentsprache](https://postext.dev/de/docs/justification.md#unterstützte-sprachen)
- [Nummerierte Bildunterschriften](https://postext.dev/de/docs/document-format.md#nummerierung-beim-ersten-verweis)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)
- [Kapitel für Kapitel gebaute Bücher](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Format der Bildunterschrift](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Tabellenformat](https://postext.dev/de/docs/configuration-resources.md#tabellenstil)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`customFonts`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#eigene-schriften), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`tableStyle`](https://postext.dev/de/docs/configuration-resources.md#tabellenstil)

**APIs**

- [`buildBundle`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern), [`bundleFontProvider`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern), [`bundleResourceBytes`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern), [`createBundle`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-erstellen), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`loadBundleFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern), [`openBundle`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-öffnen), [`registerBundleImages`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)

**Schriften**

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

## Zubereitung

### 1 · Die Ausgabe schreiben, dann nur die Bytes zurücklesen

```js
// script.js, Zeilen 294–310
// 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 file; `localized` would add the other edition to it
  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` packt Kapitel, Konfiguration, Ressourcen und jede Datei, die sie nennen, in ein Zip; `openBundle` bekommt nichts als diese Bytes. Die Schriften kommen aus `loadBundleFonts`, die Zeichnungen aus `registerBundleImages` und die Gestaltung aus `bundle.config`. Eine Schrift, die in `files` fehlt, druckt in der Ersatzschrift des Browsers, und eine fehlende Zeichnung entfällt mit einer Warnung, während ihr Verweis (?) druckt. Im Zip wird jede `fileId` zu einem Pfad (`mill.svg` heißt jetzt `resources/mill.svg`), und die Ressourcen und `customFonts`, die `openBundle` zurückgibt, verwenden bereits die neuen Namen ([ein Paket öffnen](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-öffnen)).

### 2 · Die Schriften in die Datei legen

```js
// script.js, Zeilen 277–289
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())];
  }))));
```

Ein Paket trägt eine Schrift, wenn `customFonts` sie nennt und `files` ihre Bytes unter der `fileId` des Schnitts enthält; `createBundle` speichert DM Sans 400 als `fonts/dm-sans-400-normal.woff2`. Der Pen lädt die sieben woff2-Dateien von Fontsource herunter, registriert aber keine davon: `loadBundleFonts` fügt die Kopien des Pakets zu `document.fonts` hinzu, bevor `buildBundle` eine Zeile misst, und das PDF nimmt seine Schriften aus denselben sieben Dateien.

### 3 · Jede Datei nach ihrer fileId benennen

```js
// script.js, Zeilen 234–267
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')] }) } } },
];
```

Die beiden Zeichnungen sind SVG-Markup, das der Pen erzeugt und unter der `svg.fileId` ihrer Ressourcen an `files` übergibt; die Tabelle besteht aus Daten und reist deshalb mit ihrer Legende in `preset.json`. Schnitt und Tabelle stehen mit `position: 'here'`: der Schnitt am Kopf der Rückseite, die Öffnungszeiten unter dem Absatz, der sie zitiert. Das Faltblatt setzt `layout.inlineResourceGap: 'above'`, wodurch eine Ressource im Textfluss ihren Abstand nur oberhalb behält, damit die Rückseite auf eine Seite passt: Die Überschrift unter dem Schnitt bringt ihren eigenen Abstand mit, und eine Zeile `:::space{lines=0.5}` nach der Tabelle lässt 3,7 mm unter ihrer Legende frei.

### 4 · Die Beschriftungen in der Sprache der Datei halten

```js
// script.js, Zeilen 55–59
  // Hyphenation patterns and the PDF's /Lang.
  locale: t({ en: 'en-us', es: 'es' }),
  // '{n}' numbers the figures 1, 2, 3: a leaflet has no chapters to number them by. Without
  // resourceTypes the reader builds Figure 1.1 or Figura 1.1, in the language of the file.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
```

Ein Leser baut die eingebauten Typen in der Sprache, die die Datei ausliefert: Ohne `resourceTypes` in ihrer Konfiguration druckt das spanische Faltblatt Figura 1.1 und Tabla 1.1, wo immer es geöffnet wird, auch in der englischen Sandbox. Das Faltblatt schreibt die Typen nur für ihre Nummern: `'{n}'` lässt die Kapitelnummer weg, die ein zweiseitiges Faltblatt nicht braucht ([Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)). Dieselbe Funktion `config()` setzt `locale`, die die spanischen Trennmuster wählt (compuer-tas auf der Rückseite) und die Sprache, die das PDF angibt.

### 5 · Dieselben Bytes weitergeben

```js
// script.js, Zeilen 316–324
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`);
```

Der Link bietet die `bytes` an, aus denen die Seiten gesetzt wurden, 132 KB mit den Schriften. In der Sandbox importiert (*Bücher → Neu → Eine .postext-Datei öffnen …*), wird die Datei zu einem spanischen oder englischen Buch mit dem Rad als Bild, entnommen aus `thumbnail`. Das PDF nimmt seine Schriften aus `bundleFontProvider`, das die Schrift des Pakets mit der nächstliegenden Strichstärke zurückgibt, im selben Stil, wenn es einen gibt, und seine Zeichnungen aus `bundleResourceBytes`, sodass keine Schrift und kein Bild ein zweites Mal heruntergeladen wird ([ein Paket setzen und rendern](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-paket-setzen-und-rendern)).

### 6 · Die Vorderseite mit einer Überschrift zeichnen

```js
// script.js, Zeilen 28–50
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'), placement, ...look });
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 }), // 48 pt from line to line
  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') }),
] } } };
```

Die Vorderseite ist die Gestaltung der Umschlagüberschrift: die Zeichnung an der Seite in voller Größe verankert, der Text der Überschrift über `{titleText}`, Dachzeile, Vorspann und Zeile mit den Eckdaten aus ihren Attributen und ein Textelement mit einem Kasten für den Sprachreiter. Die Ebene H1 hat `span: 'page'`, sodass die Zeichnung bis zur Unterkante des Papiers reicht. Eine Überschriftengestaltung, die in der Spalte bleibt, wird am Fuß des Satzspiegels abgeschnitten: Die Zeichnung endete 13 mm über der Unterkante.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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.25.0
import { createBundle, openBundle, loadBundleFonts, registerBundleImages, buildBundle,
  bundleFontProvider, bundleResourceBytes, defaultResourceTypes, renderPageToCanvas }
  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' };
// Each colour carries its hex and the palette entry it follows.
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'), placement, ...look });
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 }), // 48 pt from line to line
  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 = () => ({
  // #region labels: the edition's language and its figure numbers, written into the file
  // Hyphenation patterns and the PDF's /Lang.
  locale: t({ en: 'en-us', es: 'es' }),
  // '{n}' numbers the figures 1, 2, 3: a leaflet has no chapters to number them by. Without
  // resourceTypes the reader builds Figure 1.1 or Figura 1.1, in the language of the file.
  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
    // In the column, the cover design is cut at the foot of the text block; span: 'page'
    // paints it down to 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: the caption and the alt text say what each one shows. 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.
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 file; `localized` would add the other edition to it
  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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `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.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  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 at = fontsourceAt(id);
    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 = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** 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, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** 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 v2 ── 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. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its 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 files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Ein zweisprachiges Paket lesen

Der Postext-Leitfaden trägt seine englischen und spanischen Kapitel, Konfiguration und Legenden in einer Datei; `{ locale }` wählt die Ausgabe, die sich in beiden Sprachen als zwölf Kapitel auf 48 Seiten öffnet, während der Link weiterhin die Datei des Faltblatts anbietet und die PDF-Schaltfläche den Leitfaden baut.

```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 });
```

### Die Abbildungen nach Kapiteln zählen

Ohne die Typen baut der Leser sie in der Sprache der Datei mit den Standardnummern, und die Abbildungen zählen ab der Umschlagüberschrift: hier Figure 1.1 und Table 1.1, in der spanischen Ausgabe Figura 1.1 und Tabla 1.1.

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

## Häufige Fehler

- Jede Ausgabe ist hier eine eigene Datei. Um beide Sprachen in einer Datei zu tragen, geben Sie `createBundle` eine Zuordnung `localized` ([Zweisprachige Pakete](https://postext.dev/de/docs/configuration-programmatic-usage.md#zweisprachige-pakete)); `openBundle(bytes, { locale })` liest dann jede der beiden Ausgaben.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: DM Sans (OFL-1.1), DM Serif Display (OFL-1.1), Instrument Sans (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 011 · Eine Quelle, Druck- und Bildschirmausgabe](https://postext.dev/de/cookbook/print-and-screen-editions.md): Beide Ausgaben aus einer Konfiguration: htmlViewer.overrides hält die dunkle Bildschirmgestaltung, applyHtmlViewerOverrides mischt sie vor jedem HTML-Build ein. · Stufe 3 (Fortgeschritten) · Magazine und Zines
- [Nº 025 · Ein echtes PDF mit denselben eingebetteten Schriften](https://postext.dev/de/cookbook/pdf-with-embedded-fonts.md): Ein Konzertprogramm als PDF exportiert. Jede Schrift wird einmal geladen, für FontFace und für das PDF, und jede Überschrift wird zu einem Lesezeichen. · Stufe 2 (Mittel) · Einzelblätter und Akzidenzen
- [Nº 007 · Ein Buch aus einzelnen Kapiteln](https://postext.dev/de/cookbook/book-from-chapters.md): buildBundle setzt fünf Markdown-Dateien als ein Buch: Jedes Kapitel beginnt rechts, Seiten-, Kapitel- und Abbildungsnummern laufen über die Dateien weiter. · Stufe 3 (Fortgeschritten) · Handbücher und Nachschlagewerke
