# Un certificat à bordure guillochée

> Des certificats de fin de stage dessinés par un style de titre : cadre guilloché dans l’en-tête, sceau et signatures dans le pied, nom dans l’ouverture.

- Version HTML: https://postext.dev/fr/cookbook/certificate-single-page
- Recette Nº 063 · Titres et ouvertures · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Feuilles volantes et éphémères
- Nécessite postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [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
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=certificate-single-page&lang=en (.postext: https://postext.dev/cookbook/certificate-single-page/en/certificate-single-page.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [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), [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)

## En bref

Des certificats de stage pour un atelier d’imprimerie, une page par élève, avec une bordure à motifs et un sceau. Écrire une seule fois le nom d’un élève donne un certificat entier.

## Ce que vous allez composer

Des certificats de fin de stage pour le Quoin Room, un atelier de typographie au plomb fictif de Providence : une page US Letter par élève. L’édition espagnole les compose en diplômes A4 pour La Cuña, un atelier de Saragosse. Une bande bleu canard garnie de lentilles guillochées crème encadre la feuille, avec une rosace crème à chaque coin ; les deux sont dessinées en code à partir de sinusoïdes et d’hypotrochoïdes. Sous un titre en capitales interlettrées, le nom de l’élève est composé en Pinyon Script de 48 pt au-dessus d’un filet doré, et la mention est centrée en Rosarivo. Un sceau rouge à deux rubans se tient entre les lignes de signature, avec une ligne d’adresse d’imprimeur dessous. Comme un seul style de titre dessine toute la page, chaque élève est un titre Markdown, et le PDF contient la classe avec un signet par nom.

**Cette recette répond à:**

- Comment placer une bordure guillochée, un sceau et des lignes de signature sur chaque page d’un certificat ?
- Comment composer des illustrations non numérotées : ornements, vignettes, logos ?
- Comment fixer un badge ou une vignette à une position fixe sur la page ?
- Comment exporter un vrai PDF dans le navigateur avec les polices incorporées ?

## La réponse courte

```js
// script.js, lignes 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"}
```

## Ingrédients

**Enseigne**

- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre): Un style de titre porte son propre en-tête et son propre pied de page, si bien que les pages liminaires, les sections ou les lettres d’un dictionnaire ont leur propre habillage.
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image): Logos, photos et ornements placés dans les en-têtes, pieds de page, ouvertures et pages de partie, dimensionnés en conservant leurs proportions.
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée): Un titre dessiné comme une composition libre de texte, de filets, de boîtes et d’images, qui réserve au-dessus du texte courant la hauteur dont il a besoin.

**Utilise aussi**

- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Texte, filets et boîtes dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Géométrie de section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Métadonnées du document](https://postext.dev/fr/docs/document-format.md#frontmatter)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Signets PDF](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#génération-pdf-configuration)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)

**Caractères**

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

## Préparation

### 1 · Laisser un style de titre dessiner la page

Le style lui-même est [la réponse courte](https://postext.dev/fr/cookbook/certificate-single-page.md#la-réponse-courte) ci-dessus. Un style de titre gouverne les pages de sa section ([styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)) : son `header` et son `footer` remplacent ceux du document, vides ici, ses `margins` fixent la zone de texte et son `advancedDesign` dessine l’ouverture. La hauteur d’une ouverture s’arrondit au nombre supérieur de lignes entières de la grille de 17 pt du texte courant. Un `minHeight` de 106 mm devient 18 lignes, si bien que la mention commence à 140 mm du bord supérieur ; en A4, `y()` le porte à 112,7 mm, soit 19 lignes, et la mention commence à 146 mm. Le bloc de titre seul ferait commencer la mention à 128 mm (134 mm en A4). Sans `marginBottom: 0`, les 0,5 em par défaut sous un titre porteraient les mêmes 106 mm à 19 lignes.

### 2 · Accrocher le cadre à la page dans l’en-tête

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

Un élément image dessine une ressource à la taille de son placement ([éléments image](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)) : ancré au coin supérieur gauche de la page avec ses deux côtés à `'fill'`, le guilloché couvre la zone de coupe, et le SVG, déclaré aux proportions de la page, la remplit sans se déformer. L’en-tête le peint par-dessus `page.backgroundColor` et ne réserve aucune place, si bien que les marges suffisent à tenir le texte à 32 mm au moins de la coupe, à l’intérieur du filet intérieur placé à 26 mm. Un filigrane placé dans l’en-tête s’imprimerait par-dessus les mots, car l’en-tête est peint après le texte. Dans un en-tête, `{attr.serial}` lit l’attribut du titre dont relève la section de la page, si bien que chaque certificat imprime son propre numéro.

### 3 · Ne garder que le bloc de titre dans l’ouverture

```js
// script.js, lignes 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) } } },
];
```

Chaque ligne est ancrée au bord `'top'` de la page, ce qui la centre, avec un `y` mesuré depuis la coupe, si bien que le bloc garde sa place quelles que soient les marges ; `y()` étire ces positions du format Letter vers l’A4. Le nom est le texte même du titre, lu par `{titleText}`, si bien que les signets et les balises du PDF le portent. Un nom long prend une deuxième ligne, puisqu’une maquette de titre renvoie son texte à la ligne (`overflow: 'wrap'`). Les lignes interlettrées n’ont besoin d’aucune correction : l’approche qui suit la dernière lettre n’entre pas dans la mesure, si bien que ce sont les lettres elles-mêmes qui sont centrées.

### 4 · Accrocher le sceau et les signatures à un même bord

```js
// script.js, lignes 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) },
];
```

Tout ce que contient le pied de page se mesure depuis `SIGN`, le bord supérieur du sceau, et la marge inférieure du style termine la zone de texte sur ce même bord, si bien qu’une mention trop longue pour la page passe à la suivante au lieu de courir sous le sceau. Placés dans l’ouverture, les lignes et les noms compteraient dans sa hauteur ([largeur et maquette avancée](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)) ; comme ils descendent sous la zone de texte, l’ouverture réclamerait toute la page et renverrait la mention à la suivante. `LINE` prend la largeur entre les marges de la page, en retire le disque de 34 mm du sceau, divise le reste par deux et ôte 3 mm, si bien que chaque ligne s’arrête à 3 mm du sceau dans les deux formats (56 mm de long en Letter, 53 mm en A4). Chaque nom et chaque fonction est un élément texte aussi large que sa ligne, ce qui le centre sous la ligne.

### 5 · Fusionner la liste de la classe en titres

```js
// script.js, lignes 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' }) };
```

Chaque rangée devient un titre `#` qui nomme le style et porte le numéro de série en attribut ([attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)), suivi du texte de `content.en.md`, si bien que trois rangées donnent trois pages. Le `breakBefore` du niveau 1, avec la parité `'any'`, fait commencer chaque titre sur une nouvelle page, sans page blanche entre les certificats ; le style en hérite.

### 6 · Composer un seul PDF pour toute la classe

```js
// script.js, lignes 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' });
```

La classe forme un seul document, si bien que `renderToPdf` écrit un unique fichier de trois pages avec un signet par titre, chacun au nom d’un élève. `renderToPdf` accepte aussi un tableau de documents, comme les chapitres d’un livre, mais un document par élève signifie un appel à `buildDocument` pour chacun. Sous forme de titres, toute la classe est mise en page en une seule composition, et la visionneuse montre les trois certificats issus de cette composition. Le PDF incorpore les quatre polices qu’impriment les pages, que le fournisseur de polices du kit va chercher sur Fontsource ([pourquoi un fournisseur de polices ?](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)).

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Imprimer un PDF par élève

Un bouton par élève compose ce certificat comme un document à part, avec `buildDocumentWithFonts` comme composition propre du pen, et nomme le fichier d’après le numéro de série ; chaque fichier pèse environ 245 Ko.

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

### Imprimer le cadre et le titre en bleu marine

Les dessins SVG et la configuration lisent le même objet `palette`, si bien que changer une entrée recolore la bande, les motifs des rosaces et les lignes du titre.

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

## Erreurs fréquentes

- **Une ouverture réserve la hauteur jusqu’à son élément ancré à la page le plus bas.** Une ouverture en maquette avancée réserve la hauteur de son élément le plus bas, et les éléments ancrés à la page ou au fond perdu sous le titre comptent aussi : un décor au pied de la page repousse donc le texte à la page suivante. Donnez à un tel élément reserve: false (depuis postext 1.5) : il est peint là où il est ancré et ne réserve rien. Les éléments qui suivent le bandeau lui-même (ancrés à son pied, ou de hauteur 'fill') ne fixent jamais sa hauteur.
- **Les éléments d’en-tête et de pied de page se peignent par-dessus le texte.** Les éléments d’en-tête et de pied de page sont peints par-dessus la page et la zone de texte ne leur fait pas de place. Gardez-les dans les marges, qui sont ce qui leur réserve de l’espace.
- **Valeurs d’attribut : pas de { ni de } ; une valeur avec " entre apostrophes simples.** Une valeur d’attribut s’arrête à l’accolade fermante : elle ne peut donc contenir ni { ni }. Une valeur qui contient un guillemet double se met entre apostrophes simples ; un signe dollar ne pose pas de problème.
- **Pas de <marker> ni de filtres dans les dessins SVG (repli en matriciel).** Une figure SVG ne reste vectorielle dans le PDF que sans <marker>, filtres ni masques ; sinon elle se replie sur une image matricielle, et des filtres profondément imbriqués peuvent la rendre blanche dans Chrome. Dessinez les pointes de flèche comme des tracés.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Rosarivo (OFL-1.1), Pinyon Script (OFL-1.1), Aboreto (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 058 · Lettre commerciale selon la norme DIN 5008](https://postext.dev/fr/cookbook/din-business-letter.md): Un devis allemand de deux pages A4. L’objet est le H1, et sa maquette fixe l’en-tête, l’adresse et le bloc d’informations aux positions de la forme B. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
- [Nº 029 · Aide-mémoire d’ancrage : une affiche d’éléments chaînés](https://postext.dev/fr/cookbook/anchoring-cheat-sheet.md): Une affiche A3 aux éléments accrochés au fond perdu, à la page, à leur emplacement ou entre eux, et une planche qui encadre chacun et en étiquette treize. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
- [Nº 025 · Un vrai PDF avec les mêmes polices incorporées](https://postext.dev/fr/cookbook/pdf-with-embedded-fonts.md): Un programme de récital exporté en PDF. Chaque caractère est téléchargé une fois, pour FontFace et pour le PDF, et chaque titre devient un signet. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
