# Ein echtes PDF mit denselben eingebetteten Schriften

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

- HTML-Fassung: https://postext.dev/de/cookbook/pdf-with-embedded-fonts
- Rezept Nº 025 · Ausgabe und Integration · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- 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/pdf-with-embedded-fonts/en/p01.webp?v=fb03800f), [2](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p02.webp?v=fb03800f), [3](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p03.webp?v=fb03800f), [4](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p04.webp?v=fb03800f)
- PDF: https://postext.dev/cookbook/pdf-with-embedded-fonts/en/pdf-with-embedded-fonts.pdf?v=fb03800f
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=pdf-with-embedded-fonts&lang=en (.postext: https://postext.dev/cookbook/pdf-with-embedded-fonts/en/pdf-with-embedded-fonts.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/pdf-with-embedded-fonts.md), [es](https://postext.dev/es/cookbook/pdf-with-embedded-fonts.md), [ca](https://postext.dev/ca/cookbook/pdf-with-embedded-fonts.md), [fr](https://postext.dev/fr/cookbook/pdf-with-embedded-fonts.md), [it](https://postext.dev/it/cookbook/pdf-with-embedded-fonts.md), [pt](https://postext.dev/pt/cookbook/pdf-with-embedded-fonts.md), [zh](https://postext.dev/zh/cookbook/pdf-with-embedded-fonts.md), [ja](https://postext.dev/ja/cookbook/pdf-with-embedded-fonts.md), [ar](https://postext.dev/ar/cookbook/pdf-with-embedded-fonts.md)

## Kurz gesagt

Ein vierseitiges Programm für ein Abendkonzert. Es zeigt, wie man es als PDF speichert, das genau dem Bildschirm entspricht, mit den Schriften darin und jeder Überschrift in einer Liste zum schnellen Springen.

## Was Sie setzen

Das Programm für ein Konzert in der Dämmerung, *Home from Sea*: vier A5-Seiten für ein Ensemble aus Sopran, Violoncello und Harfe. Der Umschlag ist nachtpetrol, mit dem Titel in goldener Fraunces Kursiv und Reihen goldener Wellenschuppen, die vom Fuß aufsteigen. Innen ist die Programmfolge eine Tabelle mit petrolfarbenem Kopf in Versalien aus der Tenor Sans und einer getönten Summenzeile. Die Werkeinführung steht im Blocksatz aus der Crimson Text. Die Texte der drei Lieder sind Gedichte von Longfellow, Stevenson und Tennyson, gesetzt mit den Einzügen ihrer gedruckten Ausgaben. Die Schaltfläche *Build the PDF* erzeugt die Datei, die Sie dem Publikum per E-Mail schicken oder auf die Website des Spielorts stellen würden. Ihre vier Seiten entsprechen dem Bildschirm Zeile für Zeile. Sie bettet nur die fünf Schriftdateien ein, die der Browser geladen hat, und jede Überschrift ist ein Lesezeichen. Für den kommerziellen Druck siehe [das druckfertige PDF](https://postext.dev/de/cookbook/print-ready-pdf.md).

**Dieses Rezept beantwortet:**

- Wie exportiere ich im Browser ein echtes PDF mit Lesezeichen und genau den Schriften der Seite?
- Warum ändern sich meine Zeilenumbrüche oder überlappen Wörter im PDF, und wie lade ich Schriften richtig?

## Die kurze Antwort

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

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

// The PDF: the same bytes as TrueType. renderToPdf asks for each face the pages paint and no
// other. A face FONTS lacks gets the closest one it has, and is logged as a stand-in: the
// status line should list none.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
```

## Zutaten

**Zeigt**

- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider): Ein Font-Provider übergibt renderToPdf die statischen TrueType-Bytes jedes Schnitts, den das Layout gemessen hat, damit das PDF genau dieselben Zeilen setzt.
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen): renderToPdf macht aus dem gesetzten Dokument oder einem ganzen Buch PDF-Bytes im Browser, mit Fortschrittsmeldungen für lange Bücher.
- [PDF-Lesezeichen](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#pdf-erzeugung-konfiguration): Ein Lesezeichenbaum aus den Überschriften, mit Teilen über ihren Kapiteln.

**Verwendet außerdem**

- [Schriften vor dem Layout](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)
- [Grundschrift](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)
- [Kolumnentitel und Seitenzahlen](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)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Tabellenformat](https://postext.dev/de/docs/configuration-resources.md#tabellenstil)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`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**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`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**

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

## Zubereitung

### 1 · Ein Download pro Schrift, für die Seite und für das PDF

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/pdf-with-embedded-fonts.md#die-kurze-antwort). Postext misst jedes Wort mit den Schriften, die der Browser beim Build geladen hat, und das PDF zeichnet jede Zeile dorthin, wo dieses Layout sie gesetzt hat. Bettet das PDF eine andere Datei ein, etwa die Standardinstanz einer variablen Schrift oder einen Systemersatz, beginnt jedes Wort zwar an seiner gemessenen Position, doch seine Buchstaben haben andere Breiten, sodass Wörter sich überlappen oder Lücken lassen. Das Rezept lädt deshalb jede Schrift einmal. Ihre Bytes werden vor dem ersten Build zu einem `FontFace`, und der Font-Provider reicht dieselben Bytes für das PDF durch `decompressWoff2`, sodass der Export keine eigenen Schriften herunterlädt.

### 2 · Erst die Schriften, dann das Layout

```js
// script.js, Zeilen 363–367
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// Every face is declared by now; one FONTS forgot would come from Fontsource, for the screen only.
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Home from Sea · a recital programme' });
```

`registerFaces()` wird erst aufgelöst, wenn jede Schrift in `FONTS` geladen ist, sodass der erste Build mit den Schriften misst, die das PDF einbetten wird. Es ist auch der einzige Build, weil `FONTS` jede Schrift auflistet, die die Seiten setzen, und `buildDocumentWithFonts` jede gesuchte Schrift bereits deklariert vorfindet ([Schriften vor dem Layout laden](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)). Diese Prüfung hilft nur dem Bildschirm. Fehlt in `FONTS` eine Schrift, fragt die Engine das Kit danach, lädt die Fontsource-Datei und setzt das Layout ohne Warnung in der Konsole erneut, doch das PDF bekommt trotzdem die nächstliegende Schrift, die der Provider hat.

### 3 · Der Export und eine Liste dessen, was er eingebettet hat

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

`renderToPdf` ruft `onProgress` in drei Phasen auf: `prepare`, sobald die Schriften und der Umschlag eingebettet sind, `pages` nach jeder Seite und `save`, bevor es die Datei schreibt. Es fragt den Provider nur nach den Schriften, die die Seiten zeichnen, hier fünf. Die Statuszeile listet die fünf Dateien, die der Provider geliefert hat, genau die aus `FONTS`, und keinen Ersatz. Ein Ersatz verweist auf eine Schrift, die in `FONTS` fehlt. Entfernen Sie Crimson Text 600, meldet die Zeile `Crimson Text 600 → 400`, und das fette *The Harbour Consort* auf Seite 4 erscheint in normaler Schriftstärke. Die Vorschau von CodePen kann kein PDF zeigen, deshalb ersetzt `offerPdf` aus dem Kit die Schaltfläche durch zwei Links: einen, der das PDF in einem neuen Tab öffnet, und einen, der es herunterlädt ([PDFs erzeugen](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)).

### 4 · Der Überschriftenbaum ist der Lesezeichenbaum

```js
// script.js, Zeilen 108–116
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a section opens on the next page, left or right: a programme has no blank pages
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
```

Im PDF zeigt das Lesezeichenfenster den Titel des Umschlags, *Programme* mit *About the music* darunter, *The texts* mit seinen drei Liedern und *The performers*. Die Gliederung folgt den Überschriftenebenen, wählen Sie sie also mit Blick auf die Lesezeichen: hier eine H1 pro Abschnitt und eine H2 pro Lied. *The performers* hat ein eigenes Überschriftenformat, eine H1 ohne Seitenumbruch und ohne Auftakt, sodass sie sich Seite 4 mit Tennysons Gedicht teilt und trotzdem ein Lesezeichen auf oberster Ebene bekommt. Das `\\`, das den Umschlagtitel in zwei Zeilen bricht, wird im Lesezeichen zu einem Leerzeichen.

### 5 · Titel und Autor aus dem Frontmatter

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

Der Umschlag druckt zwei Werte aus dem Frontmatter über `{author}` und `{subtitle}`, und die Dokumenteigenschaften des PDF nehmen den Titel *Home from Sea* und den Autor *The Harbour Consort* aus demselben Block. Datum und Spielort sind Attribute der Umschlagüberschrift. Die Zeichnung dahinter ist ein SVG in Seitenbreite, und das PDF behält es als Vektorpfade.

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

### script.js

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

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

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

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

// The PDF: the same bytes as TrueType. renderToPdf asks for each face the pages paint and no
// other. A face FONTS lacks gets the closest one it has, and is logged as a stand-in: the
// status line should list none.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

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

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

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // set solid
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  ...style }); // in a heading design a long text wraps
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

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

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

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

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a section opens on the next page, left or right: a programme has no blank pages
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

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

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

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

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

::resource{id="order"}

## About the music

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

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

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

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

## The Tide Rises, the Tide Falls

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

The twilight darkens, the curlew calls;

Along the sea-sands damp and brown

The traveller hastens toward the town,

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

:::space

Darkness settles on roofs and walls,

But the sea in the darkness calls and calls;

The little waves, with their soft, white hands,

Efface the footprints in the sands,

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

:::space

The morning breaks; the steeds in their stalls

Stamp and neigh, as the hostler calls;

The day returns, but nevermore

Returns the traveller to the shore,

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

:::space

## Requiem

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

Dig the grave and let me lie.

Glad did I live and gladly die,

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

:::space

This be the verse you grave for me:

*Here he lies where he longed to be*;

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

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


:::pagebreak

## Crossing the Bar

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

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

And may there be no moaning of the bar,

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

:::space

But such a tide as moving seems asleep,

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

When that which drew from out the boundless deep

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

:::space

Twilight and evening bell,

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

And may there be no sadness of farewell,

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

:::space

For tho’ from out our bourne of Time and Place

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

I hope to see my Pilot face to face

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


# The performers {style="aside"}

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

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

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

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

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// The faces the pages set, and no other. Tenor Sans has 400 only (gotcha: faked-font-styles).
const FONTS = { 'Crimson Text': ['400', '400i', '600'], Fraunces: ['300i'],
  'Tenor Sans': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the faces first, then the layout, then a check that nothing was missed
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// Every face is declared by now; one FONTS forgot would come from Fontsource, for the screen only.
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Home from Sea · a recital programme' });
// #endregion

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

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

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

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

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

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

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

## Varianten

### Die Lesezeichen weglassen

Ein einseitiger Flyer oder ein Plakat hat nichts, was Lesezeichen bräuchte. Mit `outlines: false` hat das PDF keine Gliederung und öffnet sich mit geschlossener Seitenleiste.

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

### Die Schriften selbst ausliefern

Richten Sie den Fetch auf Ihre eigenen Kopien derselben statischen WOFF2-Dateien, eine pro Schriftstärke und Stil, unter den Dateinamen von Fontsource. Seite und PDF teilen sich weiterhin jeden Download.

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

## Häufige Fehler

- **Ein fehlender Fett- oder Kursivschnitt wird am Bildschirm nachgeahmt, im PDF nicht.** Verlangt ein Text eine Schriftstärke oder einen Stil, den die Schriftfamilie nicht mitbringt (ein fetter Tabellenkopf in einer Auszeichnungsschrift mit nur einem Schnitt, kursiv in einer Grotesk ohne Kursive), erzeugt der Browser ihn auf dem Canvas und in HTML synthetisch und verdickt oder neigt den aufrechten Schnitt bei gleichen Breiten. Ein PDF bettet nur echte Schnitte ein, dort druckt also der nächstliegende Schnitt des Providers normal. Nehmen Sie nur Schnitte in Ihre Schriftliste auf, die die Familie mitbringt, und stellen Sie jeden Stil passend ein, etwa tableStyle.headerBold: false.
- **Fontsource-Dateien decken Buchstaben ab, keine Symbole.** Das Kit bettet die latin-Datei einer Schrift von Fontsource ein, dazu ihre latin-ext-Datei für č, ł, † und ihre greek-Datei für α, χ (bei einer Familie, die eine mitliefert: Lora und Gelasio tun es nicht), wenn der Text diese Zeichen setzt, auf dem Bildschirm und im PDF. Symbole wie →, ≈, ✓ und ★ stehen in keiner dieser Dateien und fehlen im PDF: zeichnen Sie sie, setzen Sie sie als Mathematik oder wählen Sie eine Schrift, deren Dateien sie enthalten.
- **Ein Überschriftenformat erbt den Seitenumbruch seiner Ebene.** Ein Eintrag in headingStyles übernimmt jedes Feld, das er auslässt, von seiner Überschriftenebene, breakBefore eingeschlossen. Ein Inhaltsverzeichnis oder Impressum, das nach einem :::pagebreak über eine H1 gestaltet wird, erbt die Parität 'odd' und landet hinter einer Vakatseite. Geben Sie einem solchen Format breakBefore: { enabled: false }.

Listen Sie in `FONTS` jede Schrift auf, die Ihre Seiten setzen, darunter Fett und Kursiv des Textes. Fehlt eine, sieht der Bildschirm trotzdem richtig aus, weil das Kit die Schrift ohne Warnung lädt, doch das PDF druckt die nächstliegende Schrift des Providers, und nur die Ersatzschriften in der Statuszeile verraten den Tausch.

Verwenden Sie statische Dateien, eine pro Schriftstärke und Stil. Aus einer variablen WOFF2 bettet das PDF nur die Standardinstanz ein, sodass ein fetter Abschnitt in normaler Schriftstärke erschiene ([Warum ein Font-Provider?](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)).

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow: Henry Wadsworth Longfellow ([Quelle](https://www.gutenberg.org/ebooks/1365)), gemeinfrei
- Text: “Requiem”, with the indents and italics of the first edition of Underwoods (1887): Robert Louis Stevenson ([Quelle](https://www.gutenberg.org/ebooks/438)), gemeinfrei
- Text: “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription: Alfred Tennyson ([Quelle](https://en.wikisource.org/wiki/Demeter_and_other_poems/Crossing_the_Bar)), gemeinfrei
- Text: The programme, the notes on the music, the performers and the colophon: Ignacio Ferro, CC-BY-4.0
- Bilder: The seigaiha waves on the cover, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Schrift: Crimson Text (OFL-1.1), Fraunces (OFL-1.1), Tenor Sans (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 040 · Hausschriften in Layout, PDF und Paket](https://postext.dev/de/cookbook/brand-fonts-identity-manual.md): Das Gestaltungshandbuch einer erfundenen U-Bahn in den Schriftdateien der Marke, einmal geladen für Layout, Schriftlieferant des PDFs und .postext-Paket. · Stufe 3 (Fortgeschritten) · Handbücher und Nachschlagewerke
- [Nº 041 · .postext hin und zurück in zwei Sprachen](https://postext.dev/de/cookbook/bundle-round-trip.md): Ein beidseitiges DL-Faltblatt, im Code als .postext-Datei gespeichert und aus deren Bytes neu gesetzt, mit Schriften, Zeichnungen und Legenden je Ausgabe. · 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
