# ギョーシェ模様で縁取った修了証

> ひとつの見出しスタイルで描く修了証。ヘッダーにギョーシェの枠、フッターに印章と署名欄、章扉の位置に受講者の名前を置きます。

- HTML版: https://postext.dev/ja/cookbook/certificate-single-page
- レシピ No. 063 · 見出しと章扉 · 難易度 2 (中級) · 出力: Canvas, PDF
- ジャンル: 一枚物とエフェメラ
- 必要なもの postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · テスト環境 1.4.1, postext-pdf 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/certificate-single-page/en/p01.webp?v=3523b0d1), [2](https://postext.dev/cookbook/certificate-single-page/en/p02.webp?v=3523b0d1), [3](https://postext.dev/cookbook/certificate-single-page/en/p03.webp?v=3523b0d1)
- PDF: https://postext.dev/cookbook/certificate-single-page/en/certificate-single-page.pdf?v=3523b0d1
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=certificate-single-page&lang=en (.postext: https://postext.dev/cookbook/certificate-single-page/en/certificate-single-page.postext)
- 最終更新: 2026-09-26
- 他の言語: [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), [zh](https://postext.dev/zh/cookbook/certificate-single-page.md), [ar](https://postext.dev/ar/cookbook/certificate-single-page.md)

## かんたんな説明

活版印刷の工房の修了証です。受講者ひとりに1ページで、模様の縁取りと印章が入ります。名前を一度書けば、修了証が1枚できあがります。

## できあがり

プロビデンスにある架空の活版工房、Quoin Roomの修了証です。受講者ひとりにUSレター1ページを使います。スペイン語版は、サラゴサの工房La CuñaのA4判の証書として組みます。クリーム色のギョーシェのレンズ模様を詰めたティールの帯が紙面を囲み、四隅にはクリーム色のロゼットを置きます。どちらも正弦曲線とハイポトロコイドからコードで描いています。字間を空けた大文字の表題の下に、受講者の名前をPinyon Scriptの48ptで金色の罫の上に組み、文言はRosarivoで中央にそろえます。署名欄のあいだには2本のリボンを垂らした赤い印章を置き、その下に1行の刊記を入れます。ページ全体をひとつの見出しスタイルが描くので、受講者ひとりがMarkdownの見出しひとつにあたり、PDFにはクラス全員が名前ごとのしおりつきで収まります。

**このレシピが答える問い:**

- 修了証の各ページにギョーシェの縁取り、印章、署名欄を入れるには？
- 番号のないアートワーク（オーナメント、カット、ロゴ）を組むには？
- バッジやステッカーをページ上の決まった位置に固定するには？
- フォントを埋め込んだ本物のPDFをブラウザーで書き出すには？

## 手短な答え

```js
// script.js, 行 41–60
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, and a design that reaches
  // below the text area loses the whole reservation (gotcha: opener-reserves-anchored).
  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"}
```

## 材料

**学べること**

- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル): 見出しスタイルが独自のヘッダーとフッターを持つので、前付け、各節、辞書の見出し文字ごとに柱やノンブルを変えられます。
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素): ヘッダー、フッター、章扉、部扉に置くロゴ、写真、飾りです。縦横比を保ったまま大きさを決めます。
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン): テキスト、罫、ボックス、画像を自由に組み合わせて描く見出しです。本文の上に必要な高さを確保します。

**ほかに使うもの**

- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [節ごとのページ構成](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [文書のメタデータ](https://postext.dev/ja/docs/document-format.md#フロントマター)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [PDFのしおり](https://postext.dev/ja/docs/configuration.md#pdf生成設定)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

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

## 作り方

### 1 · ひとつの見出しスタイルにページを描かせる

スタイルそのものは上の[手短な答え](#手短な答え)にあります。見出しスタイルは、その節に属するページを受け持ちます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。`header`と`footer`は文書のもの（ここでは空）を置き換え、`margins`は本文領域を決め、`advancedDesign`は章扉を描きます。章扉の高さは、17ptの本文グリッドの行単位に切り上げられます。`minHeight`の106 mmは18行になるので、文言は紙の上端から140 mmで始まります。A4では`y()`がこれを112.7 mm、つまり19行にし、文言は146 mmから始まります。表題のブロックだけなら、文言は128 mm（A4では134 mm）から始まるところです。`marginBottom: 0`がなければ、見出しの下に既定で入る0.5 emのせいで、同じ106 mmが19行になります。

### 2 · 枠はヘッダーからページに掛ける

```js
// script.js, 行 64–78
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', space(1.6) - 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.' })),
];
```

画像要素は、配置した大きさでリソースを描きます（[画像要素](/ja/docs/configuration#画像要素)）。ページの左上隅に固定し、縦横とも`'fill'`にすると、ギョーシェは仕上がり全体を覆います。SVGはページと同じ縦横比で宣言してあるので、伸び縮みせずに収まります。ヘッダーはこれを`page.backgroundColor`の上に描き、場所は取りません。本文を仕上がりから少なくとも32 mm、26 mmにある内側の罫より内に保つのは余白だけの仕事です。ヘッダーに透かしを置くと文字の上に刷られます。ヘッダーは本文の後に描かれるからです。ヘッダーの中の`{attr.serial}`は、そのページが属する節の見出しの属性を読むので、修了証はそれぞれ自分の番号を印字します。

### 3 · 章扉には表題のブロックだけを置く

```js
// script.js, 行 82–102
const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top));
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, 2.4) },
  { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
    fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) },
  { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
    ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) },
  { 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, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…'
    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) } } },
];
```

各行はページの`'top'`辺に固定して中央にそろえ、`y`は仕上がりから測ります。こうすれば余白がどうであれブロックの位置は変わりません。`y()`はこの位置をレターからA4へ引き伸ばします。名前は見出しの本文そのもので、`{titleText}`で読むため、PDFのしおりとタグにも名前が入ります。`overflow: 'wrap'`にすると長い名前は2行目に送られます。既定のままだと、省略記号で切り詰められます。`centred()`は字間を空けた各行を、そのトラッキングの半分だけ右へずらします。これがないと、各行はその分だけページの中心より左に寄ります。上の小見出しで0.4 mm、*Certificate*で0.5 mm、*of completion*で0.7 mmです。

### 4 · 印章と署名をひとつの線から吊るす

```js
// script.js, 行 106–129
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, space(1.4) / 2) },
  ]),
  { 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) },
];
```

フッターの中身はすべて、印章の上端である`SIGN`から測ります。スタイルの下余白も同じ線で本文領域を終わらせるので、ページに収まらないほど長い文言は、印章の下にもぐらずに次のページへ送られます。署名欄と名前を章扉に移すと、その高さに数えられてしまいます（[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。それらは本文領域より下まで届くため、1.4.1では確保した高さ全体が捨てられ、文言は上余白から7.6 mm下、表題に重なる位置から始まります。`LINE`は左右の余白のあいだの幅から印章の円盤34 mmを引き、残りを半分にして3 mmを引きます。こうして各署名欄は、どちらの判型でも印章の3 mm手前で止まります（レターで56 mm、A4で53 mm）。名前と肩書きはそれぞれ署名欄と同じ幅のテキスト要素なので、欄の下の中央に収まります。

### 5 · 名簿を見出しに差し込む

```js
// script.js, 行 163–173
// 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' }) };
```

名簿の各行は、スタイルを指定し、通し番号を属性に持つ`#`見出しになり（[見出しの属性](/ja/docs/document-format#見出しの属性)）、その後に`content.en.md`の文言が続きます。3行なら3ページです。レベル1の`breakBefore`はパリティが`'any'`なので、見出しごとに新しいページを始め、修了証のあいだに白ページははさまりません。スタイルはこの設定を引き継ぎます。

### 6 · クラス全員でひとつのPDFにする

```js
// script.js, 行 271–276
// The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the
// kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles).
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' });
```

クラスはひとつの文書なので、`renderToPdf`は3ページのファイルを1本書き出し、見出しごとに受講者の名前のしおりをつけます。`renderToPdf`は本の各章のような文書の配列も受け取れますが、受講者ごとに文書を分けると、ひとりにつき`buildDocument`を1回呼ぶことになります。見出しにしておけばクラス全員が一度のビルドで組まれ、ビューアーはそのビルドから3枚の修了証をすべて表示します。PDFは、ページで使う4つの書体に加えて、RosarivoのボールドとボールドイタリックをFontsourceに求めますが、Fontsourceにはこの2つがありません。キットのフォントプロバイダーは、求められた書体をそれぞれファミリーにあるいちばん近い書体に寄せます（[なぜフォントプロバイダーが必要か](/ja/docs/configuration#なぜフォントプロバイダーが必要か)）。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: 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.4.1
// End-of-course certificates, one page per student, drawn by one heading style, in one PDF.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, 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) } });
// A tracked line is measured with a letter space after its last letter, so a centred one
// sits half a space left and a right-set one stops a space short: space() gives it in mm.
const space = (tracking) => (tracking * 25.4) / 72;
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, and a design that reaches
  // below the text area loses the whole reservation (gotcha: opener-reserves-anchored).
  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', space(1.6) - 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, tracking = 0) => at('top', space(tracking) / 2, y(top));
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, 2.4) },
  { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
    fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) },
  { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
    ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) },
  { 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, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…'
    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, space(1.4) / 2) },
  ]),
  { 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 = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  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'),
    // Centred lines get no runt check (gotcha: ragged-runts): the wording was fitted by hand.
    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 style inherits it (gotcha: headings-drop-h1-break).
    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, which could not see the web fonts (gotcha:
// svg-no-webfonts). 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 (gotcha: fonts-first)
const FONTS = { Rosarivo: ['400', '400i'], 'Pinyon Script': ['400'], Aboreto: ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, merged);
await loadSvg('guilloche.svg', guillocheSvg());
await loadSvg('seal.svg', sealSvg());
const doc = await buildWithFonts(() => buildDocument({ markdown: merged, metadata, resources },
  config()), merged);
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
// The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the
// kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles).
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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── Kit · images v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 受講者ごとにPDFを分ける

受講者ひとりにつきボタンをひとつ置き、新しい`config()`で、その修了証だけを独立した文書としてビルドし、ファイル名に通し番号をつけます。1ファイルは約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) {
+  offerPdf(() => renderToPdf(buildDocument({ markdown:
+    `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`, metadata, resources },
+    config()), { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${serial}.pdf`);
+  document.querySelector(`[data-postext-pdf="${serial}.pdf"]`).textContent = name;
+}
```

### 枠と表題を紺で刷る

SVGの図版と設定は同じ`palette`オブジェクトを読むので、1項目を変えれば帯、ロゼットの模様、表題の行の色が変わります。

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

## よくあるつまずき

- **章扉はページに固定した要素のうち最も下のものまで高さを確保する.** 詳細デザインの章扉は、最も下にある要素までの高さを確保します。見出しより下にあるページや裁ち落としに固定した要素もこれに含まれるため、ページの下端に置いた装飾があると本文が次のページへ押し出されます。こうした装飾は見出しより上に置くか、ヘッダーやフッターのスロットに移すか、minHeightで確保する高さを指定してください。
- **ヘッダーとフッターの要素は本文の上に描かれる.** ヘッダーとフッターの要素はページの上に重ねて描かれ、本文の領域はそのための場所を空けません。要素は余白の中に収めてください。その場所を確保するのは余白です。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **属性値には{も}も書けない。"を含む値は一重引用符で囲む.** 属性値は閉じ波かっこで終わるため、{や}を含められません。二重引用符を含む値は一重引用符で囲みます。ドル記号は問題ありません。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **行末不ぞろいのテキストはラントを検査されない.** optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **SVGの図に<marker>やフィルターを使わない（ラスターにフォールバックする）.** SVGの図がPDFでベクターのまま残るのは、<marker>、フィルター、マスクを使っていない場合だけです。使っているとラスターにフォールバックし、深く入れ子にしたフィルターはChromeで図を真っ白にすることがあります。矢じりはパスで描いてください。
- **PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する.** renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 1.4.1では、`letterSpacing`を指定して中央にそろえたデザインテキストは、アンカーから字間の半分だけ左に寄ります。右そろえのものは字間ひとつ分手前で終わります。テキストの幅に、最後の文字の後のトラッキングまで含まれるからです。`space()`は字間を空けた行を元の位置へ戻します。
- postext-pdf 1.4.1は、SVGを描くページごとに、そのコンテンツストリームへSVGを書き込み、共有のコピーを持ちません。枠はページごとに約210 KBを足すので、3ページのPDFは694 KB（枠なしなら58 KB）になり、30人のクラスなら約6.6 MBになります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Rosarivo (OFL-1.1), Pinyon Script (OFL-1.1), Aboreto (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 058 · DIN 5008に従うビジネスレター](https://postext.dev/ja/cookbook/din-business-letter.md): A4で2ページのドイツ語の見積書です。件名をH1にし、そのデザインでレターヘッド、宛名、情報ブロックを書式Bの位置に固定します。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 029 · アンカー早見表：要素を連ねて組むポスター](https://postext.dev/ja/cookbook/anchoring-cheat-sheet.md): 要素を裁ち落とし、ページ、スロット、ほかの要素から吊るして組んだA3の講演会ポスター。2枚目では各要素を枠で囲み、13の要素にタグを付けます。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 025 · 同じフォントを埋め込んだ本物のPDF](https://postext.dev/ja/cookbook/pdf-with-embedded-fonts.md): PDFに書き出すリサイタルのプログラム。各書体はFontFace用とPDF用を兼ねて1回だけ取得し、見出しはそれぞれしおりになります。 · 難易度 2 (中級) · 一枚物とエフェメラ
