# アクセシブルなタグ付きPDF（PDF/UA）

> 代替テキスト、表の見出しセル、文書の言語、リンク付きの目次を備えたタグ付きPDF/UA-1として書き出したごみ分別ガイド。プレビューでは3ページ目にタグを描き込みます。

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

## かんたんな説明

町の役所が配る4ページのごみ分別ガイドです。画面読み上げソフトがたどれるPDFの作り方を示します。絵には説明文が付き、表も読み上げられます。

## できあがり

『Reciclar en el barrio』は、架空のスペインの役所が作ったA4判4ページのごみ分別ガイドです。画面で読むことも、家で印刷することも想定しています。紺の表紙には、黄色の縁石の上に5つの街頭ごみ容器が並びます。2ページ目には目次と3つの囲み、その横に環境担当議員の挨拶文を置き、3・4ページ目に2つの節を、Atkinson Hyperlegible Nextの左そろえ（行末不ぞろい）2段組みで組みます。CodePenのサンプルは、veraPDFのPDF/UA-1検査に合格するタグ付きPDFを書き出します。見出し、リスト、囲み、表が読み上げ順に並び、図には代替テキストが、2つの表には見出しセルが付きます。ファイルは題と言語（`es`）を宣言し、しおりと目次の各行はそれぞれのページにリンクします。タグは印刷されないため、サンプルはプレビューの3ページ目にタグを描き込みます。ここに載せたのがそのページで、タグ付きの各ブロックを番号付きの枠で囲み、タグ名を添えています。ダウンロードしたPDFにはこの重ね描きはありません。

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

- 代替テキスト、表の見出しセル、文書の言語を備えたタグ付きPDF（PDF/UA）はどう書き出しますか？
- 「図」や「表」のラベルを文書の言語で表示するには？
- 自動で更新される目次（リーダー、ノンブル、著者名、部の行）を追加するには？
- フォントを埋め込んだ本物のPDFをブラウザーで書き出すには？

## 手短な答え

```js
// script.js, 行 48–71
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
  caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
  placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
  table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
    rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
      ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
    svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
    caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
    altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
      + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
      + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
  table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
    'Once residuos y el contenedor de cada uno.'),
  table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
    'Qué días y desde qué hora se vacía cada contenedor.'),
  { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
    svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
  resourceBytes: imageBytes }); // accessible and outlines default to true
```

## 材料

**学べること**

- [アクセシブルなタグ付きPDF](https://postext.dev/ja/docs/configuration.md#pdf生成設定): 読む順に並んだPDF/UA-1の構造ツリーです。見出し、リスト、見出しセル付きの表、代替テキスト付きの図、数式、タイトル、言語を含みます。
- [目次](https://postext.dev/ja/docs/configuration.md#目次): 見出しから作る:::tocです。点線のリーダー、印刷どおりのページラベル、著者名の行、色付きの部の行、クリックできるPDFの項目を持ちます。

**ほかに使うもの**

- [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#リソースの種類)
- [データから作る表](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [その場に置く図](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [色見本](https://postext.dev/ja/docs/document-format.md#インライン書式)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.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/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#ページをビットマップに描画する)
- citations
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [意図してグリッドを外す](https://postext.dev/ja/docs/architecture.md#グリッドを崩す要素)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [段落スタイル](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/document-format.md#タイトル内の改行)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](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#ハイフネーション), [`orderedLists`](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#表スタイル), [`toc`](https://postext.dev/ja/docs/configuration.md#目次), [`unorderedLists`](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), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`parseTSV`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`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の生成)

**書体**

- Atkinson Hyperlegible Next (OFL-1.1), Atkinson Hyperlegible Mono (OFL-1.1), Public Sans (OFL-1.1)

## 作り方

### 1 · 文書の言語と題

```js
// script.js, 行 127–129
  locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
```

postext-pdfはタグの言語を`locale`から書き込みます。ここでは`/Lang es`となり、画面読み上げソフトはガイドをスペイン語の発音で読みます。指定がなければファイルは`en-US`を宣言します。`content.es.md`のフロントマターにある`title`と`author`はPDFの題と著者になり、ファイルはビューアーに対して、ウィンドウのタイトルバーにファイル名ではなくこの題を表示するよう求めます。`defaultResourceTypes(LANG)`は、キャプションと引用にスペイン語の名前を与えます（「Figura 1.1」「tabla 1.1」）。[手短な答え](#手短な答え)で、図と表にアクセシブルなテキストを与えます。`altText`は図の代替説明（`/Alt`）と各表の`/Summary`になり、2つの表の1行目と1列目は見出しセルとしてタグ付けされます。

### 2 · ひとつのアウトラインと、題だけを載せた表紙の帯

```js
// script.js, 行 75–105
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
  text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
  text('title', '{titleText}', display(28, 'paper'),
    { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
  { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
    placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
  { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
    placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
  text('title', '{titleText}', display(66, 'paper'),
    { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
  advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
  { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
    layout: { layoutType: 'single' }, footer: { elements: [] } },
  plain('indice', { toc: false }), // the contents leave their own heading out
  plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
  levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
    { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
      span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
    { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
      marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
  ] };
```

タグツリーのH1要素とH2要素はMarkdownの見出しから作られ、PDFのしおりも同じ一覧から作られます。そのため表紙、目次、挨拶文、2つの節はレベル1、中見出しはレベル2になります。postext-pdfは見出しのデザインに含まれるテキスト要素をすべてその見出しの一部としてタグ付けします。そこで表紙の帯には題だけを載せ、リード文と役所の名前は帯の下に段落として組みます。紺の帯、縁石、並んだ容器はデザイン要素です。PDFはデザインの箱と画像をアーティファクトとして扱うので、代替テキストは要らず、画面読み上げソフトはこれらを読み飛ばします。

### 3 · リンクする目次

```js
// script.js, 行 109–114
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
  numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
  unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
  // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
```

`:::toc`はH1とH2を、印刷されるページ表記とともに並べます。PDFでは11行それぞれが該当ページの先頭にリンクします。3ページ目の`:ref`による引用「figura 1.1」「tabla 1.1」と、4ページ目の「tabla 2.1」は、図と表にリンクします。リーダーにはページ番号と同じ書体、Atkinson Hyperlegible Next Boldを使います。Public Sansではピリオドを続けて組むと、リーダーの計算の基準にする1つの点より間隔が広くなり、点がページ番号に食い込んでいたためです。

### 4 · ページに描いた読み上げ順序

```js
// script.js, 行 301–318
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
  resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
  const ids = new Map(); // element → its number
  const add = (block, box) => {
    const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
    if (!ids.has(key)) ids.set(key, ids.size + 1);
    return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
  };
  const blocks = page.columns.flatMap((c) => c.blocks);
  const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []),
  ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
  ...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
```

画面読み上げソフトはタグをたどります。`renderToPdf`は各ページを描く順にタグを付けます。帯の題、1段目の上から下、2段目、最後にそのページのフロートという順です。この関数は同じ順序をたどり直して3ページ目の枠に番号を振ります。PDFの構造ツリーにも同じ14の要素が同じ順に並びます。図と2つの表を`placement: { position: 'here' }`と`::resource`行で本文の流れの中に置いたのは、この順序のためです。1段目の下端へフロートとして配置すると、図1.1はそのページ最後のブロックである表1.1の後にタグ付けされてしまいます。2段目へ続くガラスの段落の番号が9ひとつなのは、PDFが2つの部分をひとつの段落としてタグ付けするからです。

### 5 · コントラスト比で選んだ色

```js
// script.js, 行 16–35
const palette = {
  ink: '#14243a', // text: 15.6:1 on white
  navy: '#173556', // bands: white type on it 12.5:1
  signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
  tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
  rule: '#aebccb', // table rules
  muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
  paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
  gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
  punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
  ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
```

PDF/UA-1は色を検査しませんが、WCAG 2.2は文字と背景の間に4.5:1を求めます。墨色は白地で15.6:1、紺の帯の上の白文字は12.5:1、フッターの灰色は7.0:1です。黄色は白地では1.8:1しかないため、紺の上（6.9:1）と塗りにだけ使います。ピクトグラムはWCAGがグラフィックに求める3:1に従い、容器に対してその比に届くものは白、黄色の容器だけは墨色にしています。`cell()`は各容器の名前を色見本の横に書き、リサイクルセンターには空の四角を付けます。色の見分けに頼る指示はひとつもありません。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 039 · Accessible tagged PDF (PDF/UA) ═════════════════════
// https://postext.dev/en/cookbook/accessible-tagged-pdf
// Code: MIT · Text: original (CC BY 4.0) · Pictograms: generated in code (CC BY 4.0)
// Fonts: Atkinson Hyperlegible Next and Mono, Public Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseTSV,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'accessible-tagged-pdf';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: text colours chosen for their contrast, and never a colour without its name
const palette = {
  ink: '#14243a', // text: 15.6:1 on white
  navy: '#173556', // bands: white type on it 12.5:1
  signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
  tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
  rule: '#aebccb', // table rules
  muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
  paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
  gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
  punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
  ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
// #endregion

const TRIM = [210, 297]; // A4, the size residents print at home
const [TOP, FOOT, SIDE, LEAD] = [20, 22, 18, 15.5]; // margins in mm, not mirrored; leading in pt
const [PX, FIGURE, STREET] = [12, [83, 30], [174, 44]]; // drawings: px per mm, sizes in mm
const face = (fontFamily, fontWeight, size, more) => ({ fontFamily, fontWeight, fontSize: pt(size),
  ...more });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // default: '…' (gotcha: overflow-ellipsis-default)

// #region answer: the alt text and header cells the tags take from the resources
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
  caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
  placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
  table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
    rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
      ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
    svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
    caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
    altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
      + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
      + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
  table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
    'Once residuos y el contenedor de cada uno.'),
  table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
    'Qué días y desde qué hora se vacía cada contenedor.'),
  { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
    svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
  resourceBytes: imageBytes }); // accessible and outlines default to true
// #endregion

// #region headings: one outline: cover and contents unnumbered, then sections 1 and 2 with H2s
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
  text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
  text('title', '{titleText}', display(28, 'paper'),
    { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
  { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
    placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
  { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
    placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
  text('title', '{titleText}', display(66, 'paper'),
    { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
  advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
  { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
    layout: { layoutType: 'single' }, footer: { elements: [] } },
  plain('indice', { toc: false }), // the contents leave their own heading out
  plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
  levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
    { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
      span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
    { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
      marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
  ] };
// #endregion

// #region contents: the H1s and H2s with their page numbers; each row links in the PDF
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
  numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
  unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
  // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
// #endregion

const label = (size, color, weight = 400) => face('Atkinson Hyperlegible Mono', weight, size,
  { color: col(color) });
const footer = { elements: [ // the PDF tags these as pagination artifacts
  text('where', '{title} · Castrovalle', { ...label(7.5, 'muted'), letterSpacing: pt(0.6),
    overflow: 'clip' }, at('container', 'bottom-left', 0, -11)),
  text('folio', '{pageNumber}', { ...label(9, 'ink', 700), align: 'right', overflow: 'clip' },
    at('container', 'bottom-right', 0, -10.6))] };

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  // #region identity: the language the PDF declares, and captions in that language
  locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
  // #endregion
  colorPalette, headings, headingStyles, toc, footer,
  header: { elements: [] }, // each page opens with an H1, so the folio goes in the footer
  page: { width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(SIDE), right: mm(SIDE) } },
  layout: { gutterWidth: mm(8) }, // two columns: the default layout
  bodyText: { fontFamily: 'Atkinson Hyperlegible Next', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
    firstLineIndent: mm(0), paragraphSpacing: true }, // ragged, so no runt check: ragged-runts
  unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(LEAD) },
  orderedLists: { color: col('ink'), fontWeight: 700, marginTop: pt(0), marginBottom: pt(LEAD) },
  paragraphStyles: [{ id: 'entradilla', fontSize: pt(17), lineHeight: pt(24) },
    { id: 'carta', fontSize: pt(12), lineHeight: pt(18), spaceBetween: pt(9) },
    // the council's imprint drops seven lines below the lead, to the cover's last line
    { id: 'sello', ...label(9, 'ink'), lineHeight: pt(LEAD), marginTop: pt(7 * LEAD) },
    { id: 'colofon', ...label(7, 'muted'), lineHeight: pt(10), marginTop: pt(LEAD) }],
  calloutStyles: [{ id: 'formatos', background: col('tint'), padding: mm(4), snapToGrid: false,
    titleStyle: { ...label(8, 'navy', 700), letterSpacing: pt(0.6), textTransform: 'uppercase' },
    marginTop: mm(4.5), body: { fontSize: pt(10), lineHeight: pt(14.5) } }],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('tint'), headerColor: col('ink'), headerFontSize: pt(9.5),
    bodyFontSize: pt(9.5), cellPadding: mm(1.35) }, // faces and colours follow the body text
  captionStyle: { fontSize: pt(9) },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Reciclar en el barrio"
subtitle: "Guía de residuos del barrio de la Estación"
author: "Ayuntamiento de Castrovalle"
---

# Reciclar \\ en el barrio {style="portada"}

:::paragraphs{style="entradilla"}
Dónde va cada residuo del barrio de la Estación, del brik de leche al sofá viejo, y a qué hora pasa cada camión.
:::

:::paragraphs{style="sello"}
**Ayuntamiento de Castrovalle** · Concejalía de Medio Ambiente · Enero de 2026
:::

# Índice {style="indice"}

:::toc

:::callout{type="formatos" title="Cómo usar esta guía"}
La sección 1 recorre la isla de contenedores de izquierda a derecha, y su tabla resuelve los residuos que más dudas dan. La sección 2 trata lo que no va al contenedor: el punto limpio, los horarios de recogida y la retirada de muebles.

Cada cuadrado de color de las tablas lleva al lado el nombre de su contenedor, para que nadie tenga que distinguir los colores.
:::

:::callout{type="formatos" title="Esta guía, en otros formatos"}
Este PDF está etiquetado. Un lector de pantalla recorre sus títulos, listas y tablas en el orden de lectura y lee la descripción de cada figura, y cada fila del índice lleva a su página.

Si la prefieres en letra grande, en lectura fácil o en papel, pídela en el 010 o en la Oficina de Atención a la Ciudadanía. Te la enviamos a casa.
:::

:::callout{type="formatos" title="Datos útiles"}
- **Información municipal:** 010, de lunes a sábado, de 8:00 a 20:00.
- **Punto limpio:** avenida de los Álamos, 14.
- **Muebles y enseres:** cita previa en el 010.
- **Atención a la Ciudadanía:** plaza Mayor, 1.
:::

:::columnbreak

# Presentación {style="presentacion"}

:::paragraphs{style="carta"}
En el barrio de la Estación viven unas 6400 personas, y cada una tira algo más de un kilo de basura al día. Hasta hace un año, casi todo acababa en el mismo sitio. Desde marzo hay contenedor marrón en todas las calles, y con él son cinco los contenedores de cada isla.

Esta guía explica qué va en cada contenedor y qué hacer con lo que no cabe en ninguno: el sofá viejo, el aceite de la sartén, las pilas o una lámpara rota. Los horarios de recogida y la dirección del punto limpio están en la última página.

Lo que se echa al contenedor marrón se convierte en compost en la planta de la comarca y vuelve a los parques y jardines del municipio. Para que ese compost sirva, la materia orgánica tiene que llegar limpia, sin bolsas de plástico ni restos de vidrio.

En la planta de clasificación, un envase echado al contenedor equivocado se aparta a mano. Una bolsa de basura en el contenedor del papel moja y mancha el cartón, y puede echar a perder la carga entera del camión.

Entre marzo y diciembre, el barrio llevó al contenedor marrón 312 toneladas de restos de comida, una de cada siete toneladas de la basura que tiró en esos meses. Este año queremos llegar a una de cada cuatro, y para eso basta con que cada casa separe sus restos de comida.

*Marta Ibarra, concejala de Medio Ambiente*
:::

# Qué va en cada contenedor

En cada calle del barrio hay una isla de cinco contenedores, siempre en el mismo orden, el de la :ref{id="contenedores" style="full" case="lower"}. Cada color recoge un tipo de residuo, pero no todo el plástico va al amarillo ni todo el vidrio al verde.

::resource{id="contenedores"}

## Amarillo: envases

Envases de plástico, latas y briks: botellas de agua y de refresco, botes de champú y de detergente, latas de conserva y de bebida, bolsas de plástico, bandejas de corcho blanco, papel de aluminio, chapas y tapas de metal. Vacíalos antes de tirarlos; no hace falta lavarlos. Un juguete o un cubo no son envases, aunque sean de plástico, y van al contenedor gris.

## Azul: papel y cartón

Periódicos, revistas, folletos, sobres, cajas de cartón y hueveras de cartón. Pliega las cajas antes de echarlas, para que quepan más. Las servilletas y el papel de cocina usados van al marrón, y los tiques de compra, de papel térmico, al gris.

## Verde: vidrio

Botellas, tarros y frascos de vidrio, sin tapas ni tapones: los de metal y plástico van al amarillo, y los de corcho, al marrón. Los vasos, las copas y los platos no son vidrio de envase: funden a otra temperatura y estropean el vidrio que se recicla. Van al gris, y los espejos y los cristales de ventana, al punto limpio.

## Marrón: orgánico

Restos de comida, crudos o cocinados: mondas de fruta, cáscaras de huevo, espinas, posos de café e infusiones, flores secas, tapones de corcho y papel de cocina sucio. Usa bolsas compostables, con la marca de la norma UNE-EN 13432: en la planta, las bolsas de plástico se retiran a mano antes de hacer el compost.

## Gris: resto

Lo que no va en ninguno de los otros cuatro: pañales y compresas, colillas, excrementos de mascotas y arena del gato, polvo de barrer, chicles, cerámica y loza rota. Antes de echar algo al gris, busca en la :ref{id="dudas" style="full" case="lower"} si tiene un sitio mejor, y echa siempre la bolsa cerrada.

::resource{id="dudas"}

# Lo que no va al contenedor

Algunos residuos no caben en ninguna isla, por su tamaño o porque contaminan. Se llevan al punto limpio, se dejan en los puntos de recogida de las tiendas o se recogen a domicilio.

## El punto limpio

Está en la avenida de los Álamos, 14, junto a la rotonda del polígono, y abre de martes a sábado, de 9:00 a 14:00 y de 16:00 a 19:30. Allí se dejan, sin coste para los vecinos:

- aceite de cocina usado, en una botella de plástico cerrada;
- pilas, baterías y bombillas;
- pequeños aparatos eléctricos, como secadores, móviles o cargadores;
- pintura, disolventes y aerosoles con restos;
- espejos, cristales de ventana y radiografías;
- restos de poda, en sacos de hasta 25 kilos.

Las pilas y las bombillas también se pueden dejar en los contenedores de las tiendas que las venden, y los medicamentos, con su caja, en cualquier farmacia. La ropa y el calzado usados tienen sus propios contenedores, en la plaza del Mercado y junto al centro de salud.

## Cuándo pasa cada camión

Los contenedores se vacían de noche, salvo el del vidrio, que se vacía por la mañana. La :ref{id="horarios" style="full" case="lower"} da el día y la hora de cada recogida. Echa las bolsas a partir de las 20:00, para que pasen el menor tiempo posible en la calle, y el vidrio, de día, porque hace ruido.

::resource{id="horarios"}

## Muebles y enseres

Un colchón, una silla o una lavadora no se dejan junto a los contenedores. Si compras un electrodoméstico nuevo, la tienda se lleva el viejo sin cobrarte nada. Lo demás lo retira gratis el servicio municipal, en la puerta de casa:

1. Llama al 010 o pide cita en la sede electrónica del Ayuntamiento.
2. Apunta el día y el número de recogida.
3. Pega en cada objeto un papel con ese número.
4. Esa noche, desde las 21:00, saca los objetos a la acera de tu portal.

Si tienes una duda que esta guía no resuelve, llama al 010, de lunes a sábado de 8:00 a 20:00, o pregunta en el punto limpio. Las preguntas que más se repitan entrarán en la próxima edición.

:::paragraphs{style="colofon"}
Castrovalle es un municipio imaginario, y esta guía se escribió para el Recetario de Postext (postext.dev). Compuesta en Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono y Public Sans (SIL Open Font License). Texto e ilustraciones: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
const dudas = String.raw`Residuo	Contenedor
Brik de leche o de zumo	amarillo
Tapa de un tarro de cristal	amarillo
Papel de aluminio	amarillo
Bolsa de patatas fritas	amarillo
Caja de pizza	azul
Frasco de colonia	verde
Servilleta de papel usada	marron
Vaso o copa de cristal	gris
Tique de compra	gris
Bombilla	punto
Aceite de cocina usado	punto
`; // TSV, as a spreadsheet exports it: residuo, contenedor
const horarios = String.raw`Contenedor	Días	Desde
amarillo	Lunes, miércoles y viernes	22:00
azul	Martes y sábados	22:00
verde	Jueves alternos	8:00
marron	Todos los días	23:00
gris	Todos los días	23:00
`; // TSV: contenedor, días, desde qué hora

// #region reading-order: the order of the tags, which is the order renderToPdf paints the page in
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
  resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
  const ids = new Map(); // element → its number
  const add = (block, box) => {
    const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
    if (!ids.has(key)) ids.set(key, ids.size + 1);
    return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
  };
  const blocks = page.columns.flatMap((c) => c.blocks);
  const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []),
  ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
  ...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
// #endregion

// #region art: the five street bins, and the reading order painted over page 3
const n = (v) => Math.round(v * 100) / 100;
const rgb = (hex) => hex.slice(1).match(/../g).map((c) => parseInt(c, 16));
const shade = (hex, k) => `#${rgb(hex).map((c) => Math.round(c * k).toString(16).padStart(2, '0'))
  .join('')}`;
const luminance = (hex) => rgb(hex).map((c) => c / 255).map((c) => (c <= 0.03928 ? c / 12.92
  : ((c + 0.055) / 1.055) ** 2.4)).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
// A white glyph where it reaches 3:1 against the bin (WCAG's figure for graphics), else ink.
const glyphOn = (hex) => (1.05 / (luminance(hex) + 0.05) >= 3 ? '#ffffff' : palette.ink);
// Pictograms in an 8 × 10 box centred on (0, 0): a bottle, a box, a wine bottle, an apple core
// and a tied bag. Strokes only, so they stay vector in the PDF.
const GLYPH = {
  amarillo: 'M-1.2 -5L1.2 -5L1.2 -3.6C2.8 -3 3 -2 3 -1L3 4.2C3 4.8 2.6 5 2 5L-2 5C-2.6 5 -3 4.8 '
    + '-3 4.2L-3 -1C-3 -2 -2.8 -3 -1.2 -3.6Z M-3 0.6L3 0.6',
  azul: 'M-4 -1L0 -3L4 -1L4 4L0 5.6L-4 4Z M-4 -1L0 1L4 -1 M0 1L0 5.6 M-4 -1L-5 -3.4L-1 -5.4L0 -3',
  verde: 'M-0.9 -5.4L0.9 -5.4L0.9 -2.2C2.6 -1.4 2.8 -0.4 2.8 0.8L2.8 4.6C2.8 5.1 2.5 5.4 2 5.4L-2 '
    + '5.4C-2.5 5.4 -2.8 5.1 -2.8 4.6L-2.8 0.8C-2.8 -0.4 -2.6 -1.4 -0.9 -2.2Z',
  marron: 'M-2.6 -3.2C-0.6 -3.8 0.6 -3.8 2.6 -3.2C1.2 -1.6 1.2 1.6 2.6 3.6C0.6 4.4 -0.6 4.4 '
    + '-2.6 3.6C-1.2 1.6 -1.2 -1.6 -2.6 -3.2Z M0 -3.6L0.4 -5.6 M0.4 -5C1.6 -6 2.8 -5.6 3.2 -5',
  gris: 'M-3.4 -1.6C-3.8 1.4 -3.4 5 0 5C3.4 5 3.8 1.4 3.4 -1.6C2.6 -2.6 1 -3 0 -3.2C-1 -3 '
    + '-2.6 -2.6 -3.4 -1.6Z M-1.6 -3.1L-2.4 -5.2L0 -4L2.4 -5.2L1.6 -3.1',
};
function binsSvg([w, h], size) { // five bins across w mm, their wheels on the foot of the drawing
  const step = w / 5;
  const bins = Object.keys(BIN).map((id, i) => {
    const [cx, bw, r] = [step * (i + 0.5), size * 0.68, size * 0.06]; // centre, width, wheel
    const [top, foot] = [h - size - r, h - r];
    const lid = `M${n(cx - bw / 2 - r / 2)} ${n(top)}L${n(cx + bw / 2 + r / 2)} ${n(top)}`
      + `L${n(cx + bw / 2)} ${n(top - size * 0.1)}L${n(cx - bw / 2)} ${n(top - size * 0.1)}Z`;
    const body = `M${n(cx - bw / 2)} ${n(top)}L${n(cx + bw / 2)} ${n(top)}L${n(cx + bw * 0.46)} `
      + `${n(foot)}L${n(cx - bw * 0.46)} ${n(foot)}Z`;
    const wheel = (x) => `<circle cx="${n(x)}" cy="${n(foot)}" r="${n(r)}" fill="${palette.ink}"/>`;
    return `<path d="${body}" fill="${BIN[id]}"/><path d="${lid}" fill="${shade(BIN[id], 0.72)}"/>`
      + wheel(cx - bw * 0.34) + wheel(cx + bw * 0.34)
      + `<path d="${GLYPH[id]}" transform="translate(${n(cx)} ${n(top + size * 0.48)}) `
      + `scale(${n(size / 16)})" fill="none" stroke="${glyphOn(BIN[id])}" stroke-width="0.8" `
      + 'stroke-linejoin="round" stroke-linecap="round"/>'; // the glyphs are drawn for a 16 mm bin
  });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" `
    + `viewBox="0 0 ${w} ${h}">${bins.join('')}</svg>`;
}
function union(boxes) { // the box around several design elements
  return boxes.reduce((u, { bbox: b }) => {
    const [x, y] = [Math.min(u.x, b.x), Math.min(u.y, b.y)];
    return { x, y, width: Math.max(u.x + u.width, b.x + b.width) - x,
      height: Math.max(u.y + u.height, b.y + b.height) - y };
  }, boxes[0].bbox);
}
function drawReadingOrder(ctx, page, scale) {
  const mmPx = (v) => (v * page.width * scale) / TRIM[0];
  const [r, pad, ink] = [mmPx(2.8), mmPx(1.2), '#d6146e']; // disc radius, box padding, magenta
  const inset = mmPx(0.6); // a gap under a box another one touches, such as a heading
  const boxOf = ({ x, y, width, height }, grow = 0) => ({ x: x * scale - pad, y: y * scale
    + inset - grow, w: width * scale + 2 * pad, h: height * scale - inset + 2 * grow });
  const tab = (label, { x, y, w }, fill) => { // a label straddling the box's top-right corner
    ctx.font = `700 ${r * 0.95}px "Atkinson Hyperlegible Mono"`;
    const lw = ctx.measureText(label).width + r * 0.8;
    ctx.fillStyle = fill;
    ctx.fillRect(x + w - lw, y - r * 0.55, lw, r * 1.1);
    ctx.fillStyle = '#ffffff';
    ctx.fillText(label, x + w - lw / 2, y + r * 0.02);
  };
  const marks = readingOrder(page).map((m) => ({ ...m, ...boxOf(m.box) }));
  ctx.save();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.lineWidth = r / 4.5;
  ctx.strokeStyle = ink;
  marks.forEach((m, i) => { // a line down each column, from one number to the next
    const p = marks[i - 1];
    if (!p || m.y < p.y) return; // no line for the jump to the next column
    ctx.beginPath();
    ctx.moveTo(p.x - r * 1.3, p.y + r);
    ctx.lineTo(m.x - r * 1.3, m.y + r);
    ctx.stroke();
  });
  for (const m of marks) {
    ctx.fillStyle = 'rgba(214, 20, 110, 0.07)';
    ctx.fillRect(m.x, m.y, m.w, m.h);
    ctx.setLineDash(m.cont ? [r / 2, r / 3] : []);
    ctx.strokeRect(m.x, m.y, m.w, m.h);
    ctx.setLineDash([]);
    ctx.fillStyle = ink;
    ctx.beginPath();
    ctx.arc(m.x - r * 1.3, m.y + r, r, 0, Math.PI * 2);
    ctx.fill();
    tab(m.cont ? `${m.tag} (cont.)` : m.tag, m, ink);
    ctx.font = `700 ${r * 1.15}px "Atkinson Hyperlegible Mono"`;
    ctx.fillText(String(m.n), m.x - r * 1.3, m.y + r * 1.05);
  }
  if (page.footer?.blocks.length) { // running heads and folios: artifacts, never read
    const foot = boxOf(union(page.footer.blocks), pad);
    ctx.strokeStyle = '#6b7480';
    ctx.setLineDash([r / 2, r / 3]);
    ctx.strokeRect(foot.x, foot.y, foot.w, foot.h);
    tab('Artifact', foot, '#6b7480');
  }
  ctx.restore();
}
function showReadingOrder(page) { // over the viewer's page, and in the Cookbook's page images
  const canvas = [...document.querySelectorAll('#pages canvas')]
    .find((c) => c.postext.page === page);
  const layer = Object.assign(document.createElement('canvas'), { width: canvas.clientWidth * 2,
    height: canvas.clientHeight * 2 }); // transparent, over the painted page
  layer.style.cssText = 'position:absolute;top:0;left:0;width:100%;background:none;box-shadow:none';
  canvas.parentElement.style.position = 'relative';
  canvas.after(layer);
  drawReadingOrder(layer.getContext('2d'), page, layer.width / page.width);
  window.__postextOverlay = (ctx, p, _, scale) => p.index === page.index
    && drawReadingOrder(ctx, p, scale);
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before layout (gotcha: fonts-first); the PDF embeds the same files (gotcha: latin-subset)
const FONTS = { 'Atkinson Hyperlegible Next': ['400', '400i', '700', '700i'],
  'Atkinson Hyperlegible Mono': ['400', '700'], 'Public Sans': ['700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('contenedores.svg', binsSvg(FIGURE, 23));
await loadSvg('calle.svg', binsSvg(STREET, 36));
const content = { markdown, resources: resources() };
const doc = await buildWithFonts(() => buildDocument(content, config()), markdown);
showPages(doc, { title: 'Reciclar en el barrio · PDF accesible' });
showReadingOrder(doc.pages[2]); // page 3: every tagged block numbered in reading order
offerPdf(() => exportPdf(doc), `${RECIPE}.pdf`);

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

## アレンジ

### 図をフロートさせる

placementを外すと、図1.1は3ページ目の1段目の下端へフロートし、PDFはそれをページ最後のブロックである表1.1の後にタグ付けします。

```diff
-  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
+  { id: 'contenedores', typeId: 'figure', kind: 'svg',
```

## よくあるつまずき

- **フロートの図は、ページの本文のあとに読まれる.** renderToPdfは、ページを描く順にタグを付けます。章扉の帯のタイトル、上から下へ各段、最後にページのフロートです。そのため、段の頭にフロートした図や表は、そのページの最後の段落と最後の表のあとに読まれ、参照する文から離れてしまいます。読者が参照した位置で出会う必要があるものは、placement 'here'と::resourceの行でインラインに置いてください。
- **ピリオドをカーニングする書体では目次のリーダーがはみ出す.** postext 1.4.1は、ドット1つの幅からリーダーのドットの数を数え、ページ番号の書体で組みます。書体によってはピリオドを連ねるとそれより広い間隔になるため（Public Sans Boldの12 ptで、150 dpiでドット1つは6.7 px、連続したドットは1つあたり7.6 px）、ドットが割り当てられた幅を超えてページ番号に触れ、行ごとにそろわなくなります。toc.pageNumberには、連ねてもピリオドの幅が変わらないfontFamily（本文の書体など）を指定してください。
- **行末不ぞろいのテキストはラントを検査されない.** optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **行末不ぞろいのテキストはハイフネーションされない.** ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。
- **不明なidの:refは'?'を印字し、エンジンは警告しない.** どのリソースにもないidへの:refは「?」を印字して何も配置せず、警告するのはSandboxだけです。参照するidがすべて存在するか確認してください。
- **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から書き直してください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **Fontsourceのlatinファイルにはラテン文字以外のグリフがない.** PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。

veraPDFはブラウザーの外で動くため、サンプルは自分のPDFを検証できません。ファイルをダウンロードして`verapdf -f ua1 accessible-tagged-pdf.pdf`を実行してください。このページのために取得したファイルは合格しています。代替テキストが絵を正しく説明しているかは検査ツールには判断できないので、画面読み上げソフトでガイドを聞いて確かめてください。

このガイドはpostext 1.4.1で取得しました。このバージョンではMarkdownのリンクは文字を印刷するだけでPDFにリンクを加えなかったため、奥付にはウェブアドレスを省略せずに印刷しています。postext 1.10からは`[text](url)`のリンクがPDF上で有効になり、リンクされた語をテキストとする`Link`要素としてタグ付けされます。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Atkinson Hyperlegible Next (OFL-1.1), Atkinson Hyperlegible Mono (OFL-1.1), Public Sans (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 024 · 入稿用PDF：裁ち落とし、トンボ、CMYK](https://postext.dev/ja/cookbook/print-ready-pdf.md): 3mmの裁ち落としとトンボを付けて組んだ展覧会のリーフレットを、地図と平面図を印刷用マスターから取るCMYKのPDFとして書き出します。 · 難易度 3 (上級) · 一枚物とエフェメラ
- [No. 025 · 同じフォントを埋め込んだ本物のPDF](https://postext.dev/ja/cookbook/pdf-with-embedded-fonts.md): PDFに書き出すリサイタルのプログラム。各書体はFontFace用とPDF用を兼ねて1回だけ取得し、見出しはそれぞれしおりになります。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 006 · 前付けはローマ数字、本文は1ページから](https://postext.dev/ja/cookbook/front-matter-roman-to-arabic.md): 表紙と前付けは番号なしの見出しで、小文字のローマ数字で数えます。:::numberingで、小説が始まる奇数ページから数え直して1にします。 · 難易度 3 (上級) · 小説・戯曲・文芸
