# 同じフォントを埋め込んだ本物のPDF

> PDFに書き出すリサイタルのプログラム。各書体はFontFace用とPDF用を兼ねて1回だけ取得し、見出しはそれぞれしおりになります。

- HTML版: https://postext.dev/ja/cookbook/pdf-with-embedded-fonts
- レシピ No. 025 · 出力と組み込み · 難易度 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/pdf-with-embedded-fonts/en/p01.webp?v=6caf7089), [2](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p02.webp?v=6caf7089), [3](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p03.webp?v=6caf7089), [4](https://postext.dev/cookbook/pdf-with-embedded-fonts/en/p04.webp?v=6caf7089)
- PDF: https://postext.dev/cookbook/pdf-with-embedded-fonts/en/pdf-with-embedded-fonts.pdf?v=6caf7089
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=pdf-with-embedded-fonts&lang=en (.postext: https://postext.dev/cookbook/pdf-with-embedded-fonts/en/pdf-with-embedded-fonts.postext)
- 最終更新: 2026-09-25
- 他の言語: [en](https://postext.dev/en/cookbook/pdf-with-embedded-fonts.md), [es](https://postext.dev/es/cookbook/pdf-with-embedded-fonts.md), [ca](https://postext.dev/ca/cookbook/pdf-with-embedded-fonts.md), [zh](https://postext.dev/zh/cookbook/pdf-with-embedded-fonts.md), [ar](https://postext.dev/ar/cookbook/pdf-with-embedded-fonts.md)

## かんたんな説明

夕べの演奏会の4ページのプログラム。画面とぴったり同じPDFとして保存する方法を示します。フォントはファイルの中に収まり、見出しは一覧からすぐに移動できます。

## できあがり

夕暮れのリサイタル『Home from Sea』のプログラムです。ソプラノ、チェロ、ハープの小編成のアンサンブルのためのA5判4ページで、表紙は夜の青緑、題は金色のFrauncesイタリックで、金色の波模様の鱗が下端から何列も立ち上がります。中面では、演奏順を表にします。見出し行は青緑の地にTenor Sansの大文字で、合計行には色を敷きます。曲目解説は両端そろえのCrimson Textです。3つの歌の歌詞はLongfellow、Stevenson、Tennysonの詩で、刊本どおりのインデントで組みます。「Build the PDF」ボタンで作るのは、聴衆にメールで送ったり会場のWebサイトに載せたりするファイルです。4ページは画面と1行ずつ一致します。ブラウザーが読み込んだ7つのフォントファイルだけを埋め込み、見出しはそれぞれしおりになります。商業印刷に入稿するなら[入稿用PDF](https://postext.dev/ja/cookbook/print-ready-pdf.md)を参照してください。

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

- しおり付きで、ページを組んだのと同じフォントを使う本物のPDFをブラウザーで書き出すには？
- 行の分割が変わったりPDFで単語が重なったりするのはなぜですか？フォントを正しく読み込むには？

## 手短な答え

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

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

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
```

## 材料

**学べること**

- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か): フォントプロバイダーが、レイアウトで計測したすべての書体の静的なTrueTypeのバイト列をrenderToPdfに渡すので、PDFでもまったく同じ行が組まれます。
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成): renderToPdfは、レイアウト済みの文書や本全体をブラウザー内でPDFのバイト列にします。長い本では進捗を報告します。
- [PDFのしおり](https://postext.dev/ja/docs/configuration.md#pdf生成設定): 見出しから作るしおりのツリーです。部はその章の上の階層に入ります。

**ほかに使うもの**

- [レイアウト前のフォント読み込み](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/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.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/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/configuration.md#ページをビットマップに描画する)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)

**設定の一覧**

- [`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#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyle`](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の生成)

**書体**

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

## 作り方

### 1 · 書体ごとに1回のダウンロードを、ページとPDFで共有する

コードは上の[手短な答え](#手短な答え)にあります。Postextは組版の時点でブラウザーが読み込んでいる書体ですべての語を計測し、PDFは各行をそのレイアウトが置いた位置に描きます。PDFが別のファイル、たとえば可変フォントの既定のインスタンスやシステムの代替フォントを埋め込むと、各語は計測した位置から始まるものの、文字の幅が違うので、語が重なったり隙間が空いたりします。そこでこのレシピは各書体を1回だけ取得します。そのバイト列は最初の組版の前に`FontFace`になり、フォントプロバイダーは同じバイト列を`decompressWoff2`に通してPDFに渡します。書き出しのために別途ダウンロードするフォントはありません。

### 2 · フォントが先、レイアウトは後

```js
// script.js, 行 363–367
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });
```

`registerFaces()`は`FONTS`のすべての書体が読み込まれたときにだけ解決するので、最初の組版はPDFが埋め込むのと同じ書体で計測します。これが唯一の組版でもあります。`FONTS`にはCrimson TextやFrauncesのブロックが求めうる太字とイタリックをすべて並べてあるので、キットの`buildWithFonts`は追加するものを見つけないからです（[計測キャッシュ](/ja/docs/configuration#計測キャッシュ)）。ただし、この確認が助けになるのは画面だけです。`FONTS`に書体が欠けていると、`buildWithFonts`はそれを読み込んでレイアウトをもう一度走らせます。ブロックがその書体で組まれている場合はコンソールに警告を出しますが、太字やイタリックの場合は何も出しません。それでもPDFには、プロバイダーが持つ最も近い書体が入ります。

### 3 · 書き出しと、埋め込んだものの一覧

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

`renderToPdf`は`onProgress`を3つの段階で呼びます。フォントと表紙を埋め込んだ後の`prepare`、各ページの後の`pages`、ファイルを書く前の`save`です。ここではプロバイダーに10の書体を求めます。状態の行は、プロバイダーが渡した7つのファイル（`FONTS`にあるものとちょうど同じ）と、3つの代用を列挙します。代用はどれもTenor Sansのものです。この書体にはレギュラー400しかなく、ここでは太字やイタリックで組むことはありません。それ以外の代用があれば、`FONTS`に欠けている書体があるということです。Crimson Text 600を外すと、状態の行は`Crimson Text 600 → 400`と報告し、4ページ目の太字の「The Harbour Consort」はレギュラーのウェイトで印刷されます。CodePenのプレビューはPDFを表示できないので、キットの`offerPdf`がボタンを2つのリンクに置き換えます。1つは新しいタブでPDFを開き、もう1つはダウンロードします（[PDFの生成](/ja/docs/configuration#pdfの生成)）。

### 4 · 見出しの木がしおりの木になる

```js
// script.js, 行 108–116
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
```

PDFのしおりパネルには、表紙の題、その下に「About the music」を持つ「Programme」、3つの歌を持つ「The texts」、「The performers」が並びます。アウトラインは見出しのレベルに従うので、レベルはしおりを念頭に置いて選びます。ここでは節ごとにH1、歌ごとにH2です。「The performers」には独自の見出しスタイルがあり、改ページも扉もないH1なので、Tennysonの詩と4ページ目を分け合いながら、最上位のしおりを得ます。表紙の題を2行に分ける`\\`は、しおりではスペースになります。

### 5 · 題と著者はフロントマターから

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

フロントマターの値はすべて引用符で囲みます。表紙は`{author}`と`{subtitle}`でそのうち2つを印刷し、PDFの文書プロパティは同じブロックから題『Home from Sea』と著者「The Harbour Consort」を取ります。日付と会場は表紙の見出しの属性です。その背後の絵はページ幅のSVG1つで、PDFはそれをベクターのパスのまま保ちます。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/pdf-with-embedded-fonts

### script.js

```js
// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════
// https://postext.dev/en/cookbook/pdf-with-embedded-fonts
// Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code
// Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
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 = 'pdf-with-embedded-fonts';

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

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

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

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

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

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // a multiple (gotcha: design-lineheight-multiple)
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

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

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

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

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

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

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

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

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

::resource{id="order"}

## About the music

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

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

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

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

## The Tide Rises, the Tide Falls

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

The twilight darkens, the curlew calls;

Along the sea-sands damp and brown

The traveller hastens toward the town,

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

:::space

Darkness settles on roofs and walls,

But the sea in the darkness calls and calls;

The little waves, with their soft, white hands,

Efface the footprints in the sands,

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

:::space

The morning breaks; the steeds in their stalls

Stamp and neigh, as the hostler calls;

The day returns, but nevermore

Returns the traveller to the shore,

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

:::space

## Requiem

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

Dig the grave and let me lie.

Glad did I live and gladly die,

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

:::space

This be the verse you grave for me:

*Here he lies where he longed to be*;

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

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


:::pagebreak

## Crossing the Bar

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

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

And may there be no moaning of the bar,

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

:::space

But such a tide as moving seems asleep,

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

When that which drew from out the boundless deep

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

:::space

Twilight and evening bell,

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

And may there be no sadness of farewell,

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

:::space

For tho’ from out our bourne of Time and Place

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

I hope to see my Pilot face to face

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


# The performers {style="aside"}

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

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

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

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

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Each bold and italic a block may ask for. Tenor Sans has 400 only (gotcha: faked-font-styles).
const FONTS = { 'Crimson Text': ['400', '400i', '600', '600i'], Fraunces: ['300', '300i'],
  'Tenor Sans': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the faces first, then the layout, then a check that nothing was missed
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });
// #endregion

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

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

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

// ─── Kit · fonts 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### しおりを入れない

1ページのチラシやポスターには、しおりを付けるものがありません。`outlines: false`にするとPDFにアウトラインはなく、サイドバーを閉じた状態で開きます。

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

### フォントを自分で配信する

取得先を、同じ静的WOFF2ファイルの自前のコピーに向けます。ウェイトとスタイルごとに1ファイルで、ファイル名はFontsourceと同じにします。ページとPDFは、引き続き各ダウンロードを共有します。

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

## よくあるつまずき

- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する.** renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。
- **ファミリーにないボールドやイタリックは、画面では擬似的に作られ、PDFでは作られない.** ファミリーが持たないウェイトやスタイル（1書体しかないラベル用フォントでの太字の表ヘッダー、イタリックのないサンセリフでのイタリック）を求めると、ブラウザーはCanvasとHTMLでそれを合成し、同じ幅のまま立体を太らせたり傾けたりします。PDFは実在するフォントしか埋め込まないため、そこではプロバイダーが返す最も近いフォントで通常の形のまま印字されます。フォントの一覧にはファミリーが持つフォントだけを入れ、tableStyle.headerBold: falseのように、すべてのスタイルをそれに合わせてください。
- **Fontsourceのlatinファイルにはラテン文字以外のグリフがない.** PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **見出しスタイルはその階層の改ページを受け継ぐ.** headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。

本文が使いうる太字とイタリックは、すべて`FONTS`に並べます。1つでも欠けていると、キットが警告なしにその書体を読み込むので画面は正しく見えますが、PDFはプロバイダーの最も近い書体で印刷され、入れ替わりに気づける手がかりは状態の行に出る代用だけです。

静的なファイルを、ウェイトとスタイルごとに1つずつ使います。可変WOFF2からは既定のインスタンスしか埋め込まれないので、太字の部分がレギュラーのウェイトで印刷されてしまいます（[なぜフォントプロバイダーが必要か](/ja/docs/configuration#なぜフォントプロバイダーが必要か)）。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow: Henry Wadsworth Longfellow ([出典](https://www.gutenberg.org/ebooks/1365)), パブリックドメイン
- テキスト: “Requiem”, with the indents and italics of the first edition of Underwoods (1887): Robert Louis Stevenson ([出典](https://www.gutenberg.org/ebooks/438)), パブリックドメイン
- テキスト: “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription: Alfred Tennyson ([出典](https://en.wikisource.org/wiki/Demeter_and_other_poems/Crossing_the_Bar)), パブリックドメイン
- テキスト: The programme, the notes on the music, the performers and the colophon: Ignacio Ferro, CC-BY-4.0
- 画像: The seigaiha waves on the cover, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- 書体: Crimson Text (OFL-1.1), Fraunces (OFL-1.1), Tenor Sans (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 040 · ブランドフォントを組版・PDF・バンドルで使う](https://postext.dev/ja/cookbook/brand-fonts-identity-manual.md): 架空の地下鉄のアイデンティティーマニュアルを、ブランド独自のフォントファイルで組みます。ファイルは一度だけ取得し、レイアウト、PDFのフォントプロバイダー、.postextバンドルで使い回します。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
- [No. 041 · 2言語の.postextの往復](https://postext.dev/ja/cookbook/bundle-round-trip.md): 両面のDLリーフレットをコードで.postextファイルに書き出し、そのバイト列から組み直します。各版が持つ書体、図、キャプションのラベルもいっしょです。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 007 · 章ごとのファイルから1冊の本を組む](https://postext.dev/ja/cookbook/book-from-chapters.md): buildBundleで5つのMarkdownファイルを1冊の本として組みます。各章は奇数ページから始まり、ページ番号、章番号、図番号はファイルをまたいで続きます。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
