# Urkunde mit Guillochenrahmen

> Kursurkunden aus einem Überschriftenformat: ein Guillochenrahmen in der Kopfzeile, Siegel und Unterschriftslinien in der Fußzeile, der Name im Auftakt.

- HTML-Fassung: https://postext.dev/de/cookbook/certificate-single-page
- Rezept Nº 063 · Überschriften und Auftakte · 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/certificate-single-page/en/p01.webp?v=7f68f71f), [2](https://postext.dev/cookbook/certificate-single-page/en/p02.webp?v=7f68f71f), [3](https://postext.dev/cookbook/certificate-single-page/en/p03.webp?v=7f68f71f)
- PDF: https://postext.dev/cookbook/certificate-single-page/en/certificate-single-page.pdf?v=7f68f71f
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=certificate-single-page&lang=en (.postext: https://postext.dev/cookbook/certificate-single-page/en/certificate-single-page.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/certificate-single-page.md), [es](https://postext.dev/es/cookbook/certificate-single-page.md), [ca](https://postext.dev/ca/cookbook/certificate-single-page.md), [fr](https://postext.dev/fr/cookbook/certificate-single-page.md), [it](https://postext.dev/it/cookbook/certificate-single-page.md), [pt](https://postext.dev/pt/cookbook/certificate-single-page.md), [zh](https://postext.dev/zh/cookbook/certificate-single-page.md), [ja](https://postext.dev/ja/cookbook/certificate-single-page.md), [ar](https://postext.dev/ar/cookbook/certificate-single-page.md)

## Kurz gesagt

Teilnahmeurkunden für eine Druckwerkstatt, eine Seite je Teilnehmer, mit gemustertem Rahmen und Siegel. Wer den Namen eines Teilnehmers einmal schreibt, bekommt eine ganze Urkunde.

## Was Sie setzen

Abschlussurkunden für den Quoin Room, eine erfundene Buchdruckwerkstatt in Providence: eine Seite im Format US Letter je Teilnehmer. Die spanische Ausgabe setzt sie als A4-Diplome für La Cuña, eine Werkstatt in Saragossa. Ein petrolfarbenes Band voller cremefarbener Guillochen-Linsen rahmt das Blatt, mit einer cremefarbenen Rosette in jeder Ecke; beides ist im Code aus Sinuskurven und Hypotrochoiden gezeichnet. Unter einem Titel in gesperrten Versalien steht der Name des Teilnehmers in der Pinyon Script zu 48 pt über einer goldenen Linie, und die Würdigung ist zentriert in der Rosarivo gesetzt. Ein rotes Siegel mit zwei Bandenden sitzt zwischen den Unterschriftslinien, darunter ein einzeiliges Impressum. Weil ein einziges Überschriftenformat die ganze Seite zeichnet, ist jeder Teilnehmer eine Markdown-Überschrift, und das PDF enthält den ganzen Kurs mit einem Lesezeichen je Name.

**Dieses Rezept beantwortet:**

- Wie setze ich einen Guillochenrahmen, ein Siegel und Unterschriftslinien auf jede Seite einer Urkunde?
- Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?
- Wie hefte ich ein Abzeichen oder einen Aufkleber an eine feste Position auf der Seite?
- Wie exportiere ich im Browser ein echtes PDF mit eingebetteten Schriften?

## Die kurze Antwort

```js
// script.js, Zeilen 38–57
const certificate = () => ({ // a function: it uses the elements defined below
  id: 'certificate',
  // Section geometry, for this style's pages only: a narrower measure, and a text area that
  // ends at the seal, so a citation too long for the page moves on instead of running under it.
  margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) },
  // Header and footer elements paint over the page and reserve nothing (gotcha:
  // header-paints-over-text), so the frame, the seal and the signatures go there. In the
  // opener the signature lines would count towards its height (gotcha:
  // opener-reserves-anchored), and a design that reaches below the text area takes the page.
  header: { elements: [frame, serial] },
  footer: { elements: signatures },
  // The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19
  // lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without
  // marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a
  // floor: the title block alone would start the citation at 128 mm (134 mm on A4).
  advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } },
  marginBottom: mm(0),
});
// Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student:
// # Imogen Achterberg {style="certificate" serial="26-031"}
```

## Zutaten

**Zeigt**

- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile): Ein Überschriftenformat bringt eigene Kopf- und Fußzeile mit, sodass Titelei, Abschnitte oder die Buchstaben eines Wörterbuchs ihre eigene Seitenausstattung erhalten.
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente): Logos, Fotos und Ornamente in Kopfzeilen, Fußzeilen, Auftakten und Teilseiten, mit beibehaltenem Seitenverhältnis skaliert.
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung): Eine Überschrift, gezeichnet als freie Komposition aus Text, Linien, Kästen und Bildern, die die nötige Höhe über dem Fließtext reserviert.

**Verwendet außerdem**

- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Abschnittsgeometrie](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [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)
- [PDF-Lesezeichen](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#pdf-erzeugung-konfiguration)

**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)

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

- Rosarivo (OFL-1.1), Pinyon Script (OFL-1.1), Aboreto (OFL-1.1)

## Zubereitung

### 1 · Ein Überschriftenformat die Seite zeichnen lassen

Das Format selbst steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/certificate-single-page.md#die-kurze-antwort). Ein Überschriftenformat bestimmt die Seiten seines Abschnitts ([Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)): Sein `header` und sein `footer` ersetzen die des Dokuments, die hier leer sind, seine `margins` legen den Textbereich fest, und sein `advancedDesign` zeichnet den Auftakt. Die Höhe eines Auftakts wird auf ganze Zeilen des Grundrasters von 17 pt aufgerundet. Eine `minHeight` von 106 mm wird zu 18 Zeilen, die Würdigung beginnt also 140 mm unter der Oberkante; auf A4 macht `y()` daraus 112,7 mm oder 19 Zeilen, und die Würdigung beginnt bei 146 mm. Der Titelblock allein ließe die Würdigung bei 128 mm beginnen (134 mm auf A4). Ohne `marginBottom: 0` würde der Standardabstand von 0,5 em unter einer Überschrift dieselben 106 mm auf 19 Zeilen bringen.

### 2 · Den Rahmen in der Kopfzeile an der Seite aufhängen

```js
// script.js, Zeilen 61–75
const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche',
  placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box
    size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions
const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6),
  content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line
  color: col('seal'), align: 'right', placement: at('top-right', -MARGIN, 29) };
const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions
const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.',
    es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })),
  svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.',
    es: 'Un sello rojo de borde dentado con dos cintas.' })),
];
```

Ein Bildelement zeichnet eine Ressource in der Größe ihrer Platzierung ([Bildelemente](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)): an der linken oberen Ecke der Seite verankert und mit `'fill'` auf beiden Seiten, bedeckt die Guilloche das Endformat, und das SVG, mit den Proportionen der Seite angelegt, füllt es, ohne sich zu verzerren. Die Kopfzeile malt es über `page.backgroundColor` und reserviert keinen Platz, sodass allein die Ränder den Text mindestens 32 mm vom Beschnitt fernhalten, innerhalb der inneren Linie bei 26 mm. Ein Wasserzeichen in der Kopfzeile würde über den Wörtern drucken, weil die Kopfzeile nach dem Text gemalt wird. In einer Kopfzeile liest `{attr.serial}` das Attribut der Überschrift, zu deren Abschnitt die Seite gehört, sodass jede Urkunde ihre eigene Nummer druckt.

### 3 · Nur den Titelblock im Auftakt behalten

```js
// script.js, Zeilen 79–99
const centred = (top) => at('top', 0, y(top)); // tracked or not, the letters are centred
const title = [
  { kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4),
    content: t({ en: 'The Quoin Room · Letterpress workshop',
      es: 'La Cuña · Taller de tipografía' }),
    placement: centred(39) },
  { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
    fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48) },
  { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
    ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68) },
  { kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }),
    fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'),
    placement: centred(90) },
  { kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script',
    fontSize: pt(48), lineHeight: 1.25, // 60 pt from line to line of a long name
    color: col('ink'), align: 'center', overflow: 'wrap', // a long name takes a second line
    placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } },
  { kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75),
    color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' },
      offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } },
];
```

Jede Zeile ist an der Kante `'top'` der Seite verankert, was sie zentriert, mit einem `y`, das vom Beschnitt aus gemessen wird, sodass der Block unabhängig von den Rändern seinen Platz behält; `y()` dehnt diese Positionen von Letter auf A4. Der Name ist der eigene Text der Überschrift, gelesen über `{titleText}`, sodass PDF-Lesezeichen und Tags ihn tragen. Ein langer Name nimmt eine zweite Zeile, da eine Überschriftengestaltung ihren Text umbricht (`overflow: 'wrap'`). Die gesperrten Zeilen brauchen keine Korrektur: Die Sperrung nach dem letzten Buchstaben bleibt aus der Messung heraus, sodass die Buchstaben selbst zentriert stehen.

### 4 · Siegel und Unterschriften an einer Kante aufhängen

```js
// script.js, Zeilen 103–126
const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']],
  es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] });
// mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4)
const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3;
const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } });
const signatures = [
  { kind: 'image', id: 'seal', resourceId: 'seal',
    placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } },
  ...SIGNED.flatMap(([who, role], i) => [
    { kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'),
      placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23),
        size: { width: mm(LINE) } } },
    { kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5),
      color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) },
    { kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5),
      letterSpacing: pt(1.4), color: col('muted'), align: 'center',
      placement: under(`who${i}`, 0.6) },
  ]),
  { kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7),
    content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto',
      es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }),
    color: col('muted'), placement: at('top', 0, SIGN + 50) },
];
```

Alles in der Fußzeile wird von `SIGN` aus gemessen, der Oberkante des Siegels, und der untere Rand des Formats beendet den Textbereich an derselben Kante, sodass eine für die Seite zu lange Würdigung auf die nächste Seite weiterläuft, statt unter das Siegel zu laufen. In den Auftakt verschoben, zählten die Linien und die Namen zu seiner Höhe ([Span und erweiterte Gestaltung](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)); da sie unter den Textbereich reichen, beanspruchte der Auftakt die ganze Seite und schickte die Würdigung auf die nächste. `LINE` nimmt die Breite zwischen den Seitenrändern, zieht die 34 mm der Siegelscheibe ab, halbiert den Rest und zieht 3 mm ab, sodass jede Linie in beiden Formaten 3 mm vor dem Siegel endet (56 mm lang auf Letter, 53 mm auf A4). Jeder Name und jede Funktion ist ein Textelement so breit wie seine Linie, was ihn unter der Linie zentriert.

### 5 · Die Teilnehmerliste in Überschriften einfügen

```js
// script.js, Zeilen 159–169
// Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values).
const CLASS = t({
  en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']],
  es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']],
});
const merged = CLASS.map(([name, serial]) =>
  `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n');
const metadata = { // the PDF's title and author
  title: t({ en: 'Certificates of completion, fall 2026',
    es: 'Diplomas del curso de otoño de 2026' }),
  author: t({ en: 'The Quoin Room', es: 'La Cuña' }) };
```

Jede Zeile wird zu einer Überschrift `#`, die das Format nennt und die laufende Nummer als Attribut trägt ([Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)), gefolgt vom Wortlaut aus `content.en.md`, sodass drei Zeilen drei Seiten ergeben. Das `breakBefore` der Ebene 1 mit der Parität `'any'` beginnt jede Überschrift auf einer neuen Seite ohne Vakatseite zwischen den Urkunden; das Format erbt es.

### 6 · Ein PDF für den ganzen Kurs bauen

```js
// script.js, Zeilen 267–270
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
  es: 'Toda la clase en un PDF' });
```

Der Kurs ist ein einziges Dokument, deshalb schreibt `renderToPdf` eine dreiseitige Datei mit einem Lesezeichen je Überschrift, jedes nach einem Teilnehmer benannt. `renderToPdf` nimmt auch ein Array von Dokumenten an, etwa die Kapitel eines Buches, aber ein Dokument je Teilnehmer bedeutet einen Aufruf von `buildDocument` für jeden. Als Überschriften wird der ganze Kurs in einem Build gesetzt, und der Betrachter zeigt alle drei Urkunden aus diesem Build. Das PDF bettet die vier Schriften ein, die die Seiten drucken und die der Schriften-Provider des Kits von Fontsource holt ([Warum ein Schriften-Provider?](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)).

## 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/certificate-single-page

### script.js

```js
// ═══ Postext Cookbook · Nº 063 · Certificate with a guilloche border ═══════════════
// https://postext.dev/en/cookbook/certificate-single-page
// Code: MIT · Text: original (CC BY 4.0) · Guilloche and seal: generated in code (CC BY 4.0)
// Fonts: Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// End-of-course certificates, one page per student, drawn by one heading style, in one PDF.
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 = 'certificate-single-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2a2e', // text and the student's name
  teal: '#1f5f5b', // the frame, the title lines
  gold: '#b08d57', // hairlines and the rule under the name; never text (2.9:1 on paper)
  seal: '#8d2c2c', // the seal and the serial number
  rule: '#8f8878', // signature lines
  muted: '#6d6a60', // the lead-in, the signatories' roles, the imprint
  paper: '#fbf8ef', // the sheet
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [W, H] = t({ en: [215.9, 279.4], es: [210, 297] }); // US Letter; A4 for the Spanish diploma
const MARGIN = 32; // mm on every side, inside the frame's inner rule (26 mm in)
const SIDE = 50; // mm: the certificate's side margins, a 116 mm measure (110 on A4)
// Positions are drawn on US Letter and stretched to A4: y(90) is 90 mm down on Letter.
const y = (mm) => (mm * H) / 279.4;
const SIGN = y(194); // mm from the top: the seal's top edge, where the text area ends
const SEAL = { w: 40, h: 45, r: 17 }; // mm: the seal with its ribbon tails, and its disc's radius
const at = (edge, x, top) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(top) } });
const caps = { fontFamily: 'Aboreto', textTransform: 'uppercase', color: col('teal') };

// #region answer: one heading style draws the whole certificate
const certificate = () => ({ // a function: it uses the elements defined below
  id: 'certificate',
  // Section geometry, for this style's pages only: a narrower measure, and a text area that
  // ends at the seal, so a citation too long for the page moves on instead of running under it.
  margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) },
  // Header and footer elements paint over the page and reserve nothing (gotcha:
  // header-paints-over-text), so the frame, the seal and the signatures go there. In the
  // opener the signature lines would count towards its height (gotcha:
  // opener-reserves-anchored), and a design that reaches below the text area takes the page.
  header: { elements: [frame, serial] },
  footer: { elements: signatures },
  // The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19
  // lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without
  // marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a
  // floor: the title block alone would start the citation at 128 mm (134 mm on A4).
  advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } },
  marginBottom: mm(0),
});
// Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student:
// # Imogen Achterberg {style="certificate" serial="26-031"}
// #endregion

// #region frame: the guilloche is an SVG resource, drawn by an image element in the header
const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche',
  placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box
    size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions
const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6),
  content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line
  color: col('seal'), align: 'right', placement: at('top-right', -MARGIN, 29) };
const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions
const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.',
    es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })),
  svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.',
    es: 'Un sello rojo de borde dentado con dos cintas.' })),
];
// #endregion

// #region title: the opener holds the title block; the name is the heading's own text
const centred = (top) => at('top', 0, y(top)); // tracked or not, the letters are centred
const title = [
  { kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4),
    content: t({ en: 'The Quoin Room · Letterpress workshop',
      es: 'La Cuña · Taller de tipografía' }),
    placement: centred(39) },
  { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
    fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48) },
  { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
    ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68) },
  { kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }),
    fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'),
    placement: centred(90) },
  { kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script',
    fontSize: pt(48), lineHeight: 1.25, // 60 pt from line to line of a long name
    color: col('ink'), align: 'center', overflow: 'wrap', // a long name takes a second line
    placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } },
  { kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75),
    color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' },
      offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } },
];
// #endregion

// #region signatures: seal, signature lines, names and roles, hung from the seal's top edge
const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']],
  es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] });
// mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4)
const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3;
const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } });
const signatures = [
  { kind: 'image', id: 'seal', resourceId: 'seal',
    placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } },
  ...SIGNED.flatMap(([who, role], i) => [
    { kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'),
      placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23),
        size: { width: mm(LINE) } } },
    { kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5),
      color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) },
    { kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5),
      letterSpacing: pt(1.4), color: col('muted'), align: 'center',
      placement: under(`who${i}`, 0.6) },
  ]),
  { kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7),
    content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto',
      es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }),
    color: col('muted'), placement: at('top', 0, SIGN + 50) },
];
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // the PDF's /Lang; centred text is never hyphenated
  colorPalette,
  page: { width: mm(W), height: mm(H), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(MARGIN), bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: 'Rosarivo', fontSize: pt(11.5), lineHeight: pt(17), color: col('ink'),
    // Ink for the italic (the course title, the date line) and for any bold you add to the
    // wording; referenceColor falls back to boldColor.
    boldColor: col('ink'), italicColor: col('ink'),
    textAlign: 'center', firstLineIndent: pt(0), paragraphSpacing: true,
  },
  // The design replaces the heading's own text, which the PDF still tags and bookmarks: set it
  // in a face the pages load, regular, since Rosarivo has no bold.
  headings: { fontFamily: 'Rosarivo', fontWeight: 400,
    // One page per student, no blank backs; the certificate style inherits it.
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] },
  headingStyles: [certificate()],
  header: { elements: [] }, // no page-wide furniture: the certificate style sets its own
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`has completed *Hand Composition and Platen Printing*, a course of forty-eight hours in the fall term of 2026, and as a final piece has set by hand and printed a broadside of their own design in an edition of forty copies.

*Given at Providence, Rhode Island, on December 4, 2026.*
`; // content.<lang>.md: the wording every certificate shares

// #region merge: the class list becomes one styled heading per student
// Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values).
const CLASS = t({
  en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']],
  es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']],
});
const merged = CLASS.map(([name, serial]) =>
  `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n');
const metadata = { // the PDF's title and author
  title: t({ en: 'Certificates of completion, fall 2026',
    es: 'Diplomas del curso de otoño de 2026' }),
  author: t({ en: 'The Quoin Room', es: 'La Cuña' }) };
// #endregion

// #region art: the guilloche frame and the seal, drawn as SVG paths
// Strokes and fills only: no <marker>, filter or mask, so the PDF keeps both drawings vector
// (gotcha: svg-no-marker-filters), and no text. No random numbers, so every build draws the
// same curves.
const TAU = 2 * Math.PI;
const r2 = (v) => Math.round(v * 100) / 100;
// A polyline in relative moves, about a tenth smaller than in absolute coordinates.
const polyline = (pts, close = false) => `M${r2(pts[0][0])} ${r2(pts[0][1])}l${pts.slice(1)
  .map(([x, y], i) => `${r2(x - pts[i][0])} ${r2(y - pts[i][1])}`).join(' ')}${close ? 'z' : ''}`;
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
// A hypotrochoid: the path of a pen at distance d from the centre of a circle of radius r
// rolling inside one of radius R, scaled to `radius` mm. It closes after r / gcd(R, r) turns.
function spiro(R, r, d, radius, steps = 1400) {
  const k = radius / (R - r + d);
  return polyline(Array.from({ length: steps + 1 }, (_, i) => {
    const a = (i / steps) * TAU * (r / gcd(R, r));
    return [((R - r) * Math.cos(a) + d * Math.cos(((R - r) / r) * a)) * k,
      ((R - r) * Math.sin(a) - d * Math.sin(((R - r) / r) * a)) * k];
  }), true);
}
// One lens of the border, `len` mm long and up to `half` mm either side of its axis: strands
// that cross at a steady rate, under an envelope that pinches them together at both ends.
const lens = (len, half, count, color) => Array.from({ length: count }, (_, k) =>
  `<path stroke="${color}" d="${polyline(Array.from({ length: 97 }, (_, i) => {
    const s = (i / 96) * len;
    const envelope = 0.56 - 0.44 * Math.cos((TAU * s) / len);
    return [s, half * envelope * Math.sin((2 * TAU * s) / len + (TAU * k) / count)];
  }))}"/>`).join('');
const BAND = [11, 23]; // the teal band, mm in from the trim; the lenses run along its middle
const MID = (BAND[0] + BAND[1]) / 2;
function guillocheSvg() {
  // Each side holds a whole number of lenses about 24 mm long: one tile per side length.
  const tiles = [['h', W - 2 * MID], ['v', H - 2 * MID]].map(([id, run]) => {
    const n = Math.round(run / 24);
    return { id, n, len: run / n, def: `<g id="${id}" stroke-width="0.2">`
      + `${lens(run / n, 5.4, 11, palette.paper)}${lens(run / n, 2.6, 3, palette.gold)}</g>` };
  });
  const use = (id, x, y, turn) => `<use href="#${id}" transform="translate(${r2(x)} ${r2(y)})`
    + ` rotate(${turn})"/>`;
  const uses = tiles.flatMap(({ id, n, len }) => Array.from({ length: n }, (_, i) => MID + i * len)
    .flatMap((p) => (id === 'h' ? [use(id, p, MID, 0), use(id, p, H - MID, 0)]
      : [use(id, MID, p, 90), use(id, W - MID, p, 90)])));
  const ring = (inset, color, width) => `<rect x="${inset}" y="${inset}"`
    + ` width="${r2(W - 2 * inset)}" height="${r2(H - 2 * inset)}" stroke="${color}"`
    + ` stroke-width="${width}"/>`;
  const box = (inset) => `M${inset} ${inset}h${r2(W - 2 * inset)}v${r2(H - 2 * inset)}`
    + `h${r2(2 * inset - W)}z`;
  // A cream medallion on the teal band, so each corner reads as a disc at thumbnail size.
  const rosette = `<g id="rosette"><circle r="10" fill="${palette.paper}" stroke="${palette.gold}"`
    + ` stroke-width="0.6"/><circle r="9.1" stroke="${palette.teal}" stroke-width="0.25"/>`
    + `<path d="${spiro(30, 13, 9, 8.2)}" stroke="${palette.teal}" stroke-width="0.16"/>`
    + `<circle r="1.4" fill="${palette.gold}"/></g>`;
  const corners = [[MID, MID], [W - MID, MID], [MID, H - MID], [W - MID, H - MID]]
    .map(([x, y]) => use('rosette', x, y, 0));
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${r2(W * PX)}" height="${r2(H * PX)}"`
    + ` viewBox="0 0 ${W} ${H}"><defs>${tiles.map((tile) => tile.def).join('')}${rosette}</defs>`
    + `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${ring(9, palette.gold, 0.35)}`
    + `<path fill="${palette.teal}" fill-rule="evenodd" d="${box(BAND[0])}${box(BAND[1])}"/>`
    + `${uses.join('')}${ring(25, palette.gold, 0.6)}${ring(26.2, palette.teal, 0.2)}`
    + `${corners.join('')}</g></svg>`;
}
function sealSvg() {
  const [cx, cy, R] = [SEAL.w / 2, 19, SEAL.r];
  const teeth = Array.from({ length: 144 }, (_, i) => [
    cx + (i % 2 ? R : R - 1.3) * Math.cos((i / 144) * TAU),
    cy + (i % 2 ? R : R - 1.3) * Math.sin((i / 144) * TAU)]);
  // Two ribbon tails behind the seal, notched at the ends; the far one a shade darker.
  const tail = (s) => polyline([[cx + s * 3, cy], [cx + s * 12, cy + 24], [cx + s * 9, cy + 21.6],
    [cx + s * 6, cy + 25.5], [cx - s * 3, cy + 3]], true);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SEAL.w * PX}" height="${SEAL.h * PX}"`
    + ` viewBox="0 0 ${SEAL.w} ${SEAL.h}"><path d="${tail(-1)}" fill="#6f2323"/>`
    + `<path d="${tail(1)}" fill="${palette.seal}"/>`
    + `<path d="${polyline(teeth, true)}" fill="${palette.seal}"/>`
    + `<g fill="none" stroke="${palette.paper}" stroke-linecap="round">`
    + `<circle cx="${cx}" cy="${cy}" r="${R - 3}" stroke-width="0.35"/>`
    + `<circle cx="${cx}" cy="${cy}" r="${R - 3.8}" stroke-width="0.15"/>`
    + `<path transform="translate(${cx} ${cy})" d="${spiro(24, 11, 9, R - 5)}"`
    + ' stroke-width="0.14"/>'
    + `</g><circle cx="${cx}" cy="${cy}" r="1.3" fill="${palette.paper}"/></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded from Fontsource before the first build: layout measures with them
const FONTS = { Rosarivo: ['400', '400i'], 'Pinyon Script': ['400'], Aboreto: ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('guilloche.svg', guillocheSvg());
await loadSvg('seal.svg', sealSvg());
const doc = await buildDocumentWithFonts({ markdown: merged, metadata, resources },
  config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Certificate with a guilloche border',
  es: 'Diploma con orla de guilloché' }) });
// #region pdf: one document with a page per student, so one PDF holds the whole class
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
  es: 'Toda la clase en un 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

### Ein PDF je Teilnehmer drucken

Eine Schaltfläche je Teilnehmer baut dessen Urkunde als eigenes Dokument, mit `buildDocumentWithFonts` als eigenem Build des Pens, und benennt die Datei nach der laufenden Nummer; jede Datei wiegt etwa 245 KB.

```diff
-offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
-  `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
-document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
-  es: 'Toda la clase en un PDF' });
+for (const [name, serial] of CLASS) {
+  const one = `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`;
+  offerPdf(async () => renderToPdf(await buildDocumentWithFonts({ markdown: one, metadata,
+    resources }, config(), kitFonts(FONTS)), { fontProvider: fontsourceProvider,
+    resourceBytes: imageBytes }), `${serial}.pdf`);
+  document.querySelector(`[data-postext-pdf="${serial}.pdf"]`).textContent = name;
+}
```

### Rahmen und Titel in Marineblau drucken

Die SVG-Grafik und die Konfiguration lesen dasselbe Objekt `palette`, sodass die Änderung eines Eintrags das Band, die Rosettenmuster und die Titelzeilen umfärbt.

```diff
-  teal: '#1f5f5b', // the frame, the title lines
+  teal: '#2b3a67', // the frame, the title lines
```

## Häufige Fehler

- **Ein Auftakt reserviert Höhe bis zu seinem tiefsten an der Seite verankerten Element.** Ein Auftakt mit erweiterter Gestaltung reserviert die Höhe seines tiefsten Elements, und an der Seite oder am Anschnitt verankerte Elemente unterhalb der Überschrift zählen mit, sodass Schmuck am Seitenfuß den Text auf die nächste Seite schiebt. Geben Sie einem solchen Element reserve: false (seit postext 1.5): es wird dort gemalt, wo es verankert ist, und reserviert nichts. Elemente, die dem Band selbst folgen (an seinem Fuß verankert oder 'fill' hoch), bestimmen nie seine Höhe.
- **Elemente von Kopf- und Fußzeile malen über den Text.** Elemente von Kopf- und Fußzeile werden über die Seite gemalt, und der Textbereich macht ihnen keinen Platz. Halten Sie sie innerhalb der Ränder, denn die Ränder reservieren ihren Raum.
- **Attributwerte: kein { oder }; einen Wert mit " in einfache Anführungszeichen setzen.** Ein Attributwert endet an der schließenden geschweiften Klammer, kann also kein { oder } enthalten. Ein Wert, der ein doppeltes Anführungszeichen enthält, kommt in einfache Anführungszeichen; ein Dollarzeichen ist unproblematisch.
- **Kein <marker> und keine Filter in SVG-Grafiken (Raster-Ersatz).** Eine SVG-Abbildung bleibt im PDF nur ohne <marker>, Filter und Masken ein Vektor; andernfalls fällt sie auf ein Raster zurück, und tief verschachtelte Filter können sie in Chrome leer erscheinen lassen. Zeichnen Sie Pfeilspitzen als Pfade.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: Rosarivo (OFL-1.1), Pinyon Script (OFL-1.1), Aboreto (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 058 · Geschäftsbrief nach DIN 5008](https://postext.dev/de/cookbook/din-business-letter.md): Ein zweiseitiges deutsches Angebot auf A4. Der Betreff ist die H1, und ihr Design setzt Briefkopf, Anschrift und Informationsblock an die Stellen von Form B. · Stufe 2 (Mittel) · Einzelblätter und Akzidenzen
- [Nº 029 · Spickzettel zum Verankern: Plakat aus verketteten Elementen](https://postext.dev/de/cookbook/anchoring-cheat-sheet.md): Ein A3-Vortragsplakat, dessen Elemente am Anschnitt, an der Seite, am Slot oder aneinander hängen, und ein Bogen, der jedes rahmt und dreizehn beschriftet. · Stufe 2 (Mittel) · Einzelblätter und Akzidenzen
- [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
