# Barrierefreies getaggtes PDF (PDF/UA)

> Ein Abfallratgeber als getaggtes PDF/UA-1 mit Alternativtext, Kopfzellen, Dokumentsprache und verlinktem Inhalt; die Vorschau zeichnet die Tags auf Seite 3.

- HTML-Fassung: https://postext.dev/de/cookbook/accessible-tagged-pdf
- Rezept Nº 039 · Ausgabe und Integration · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Berichte
- Benötigt postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/accessible-tagged-pdf/es/p01.webp?v=31e1a8c1), [2](https://postext.dev/cookbook/accessible-tagged-pdf/es/p02.webp?v=31e1a8c1), [3](https://postext.dev/cookbook/accessible-tagged-pdf/es/p03.webp?v=31e1a8c1), [4](https://postext.dev/cookbook/accessible-tagged-pdf/es/p04.webp?v=31e1a8c1)
- PDF: https://postext.dev/cookbook/accessible-tagged-pdf/es/accessible-tagged-pdf.pdf?v=31e1a8c1
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=accessible-tagged-pdf&lang=es (.postext: https://postext.dev/cookbook/accessible-tagged-pdf/es/accessible-tagged-pdf.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [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), [fr](https://postext.dev/fr/cookbook/accessible-tagged-pdf.md), [it](https://postext.dev/it/cookbook/accessible-tagged-pdf.md), [pt](https://postext.dev/pt/cookbook/accessible-tagged-pdf.md), [zh](https://postext.dev/zh/cookbook/accessible-tagged-pdf.md), [ja](https://postext.dev/ja/cookbook/accessible-tagged-pdf.md), [ar](https://postext.dev/ar/cookbook/accessible-tagged-pdf.md)

## Kurz gesagt

Ein vierseitiger Abfallratgeber einer Stadtverwaltung. Er zeigt, wie ein PDF entsteht, dem Screenreader folgen können, mit Bildbeschreibungen und Tabellen, die sie vorlesen können.

## Was Sie setzen

*Reciclar en el barrio* ist ein vierseitiger Abfallratgeber im Format A4 von einer erfundenen spanischen Stadtverwaltung, zum Lesen am Bildschirm oder zum Ausdrucken zu Hause. Der marineblaue Umschlag zeigt die fünf Straßentonnen auf einer gelben Bordsteinkante. Seite 2 enthält das Inhaltsverzeichnis und drei Kästen neben einem Brief des Dezernenten, die Seiten 3 und 4 die beiden Abschnitte in zwei Spalten im Flattersatz aus der Atkinson Hyperlegible Next. Der Pen exportiert ein getaggtes PDF, das die PDF/UA-1-Prüfungen von veraPDF besteht: Überschriften, Listen, Kästen und Tabellen in Lesereihenfolge, Alternativtext an der Abbildung und Kopfzellen in beiden Tabellen. Die Datei nennt ihren Titel und ihre Sprache (`es`), und ihre Lesezeichen wie die Zeilen des Inhaltsverzeichnisses verlinken auf die Seiten. Tags werden nicht gedruckt, deshalb zeichnet der Pen sie über Seite 3 seiner Vorschau, die hier zu sehen ist: jeden getaggten Block in einem nummerierten Rahmen, beschriftet mit seinem Tag. Das heruntergeladene PDF hat diese Überlagerung nicht.

**Dieses Rezept beantwortet:**

- Wie exportiere ich ein getaggtes PDF (PDF/UA) mit Alternativtext, Tabellenkopfzellen und Dokumentsprache?
- Wie erhalte ich die Beschriftungen „Abbildung“ und „Tabelle“ in der Sprache meines Dokuments?
- Wie füge ich ein Inhaltsverzeichnis hinzu, das sich selbst aktualisiert (Führungspunkte, Seitenzahlen, Autoren, Teilzeilen)?
- Wie exportiere ich im Browser ein echtes PDF mit eingebetteten Schriften?

## Die kurze Antwort

```js
// script.js, Zeilen 47–70
// 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' }, // set under the sentence that cites it
  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
```

## Zutaten

**Zeigt**

- [Barrierefreies getaggtes PDF](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#pdf-erzeugung-konfiguration): Ein PDF/UA-1-Strukturbaum in Lesereihenfolge: Überschriften, Listen, Tabellen mit Kopfzellen, Abbildungen mit Alternativtext, Formeln, Titel und Sprache.
- [Inhaltsverzeichnis](https://postext.dev/de/docs/configuration-notes-references.md#inhaltsverzeichnis): Ein :::toc, aus den Überschriften aufgebaut: Füllpunkte, Seitenbezeichnungen wie gedruckt, Autorenzeilen, farbige Teilzeilen und anklickbare Einträge im PDF.

**Verwendet außerdem**

- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [PDF-Lesezeichen](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#pdf-erzeugung-konfiguration)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Tabellen aus Daten](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Farbfelder](https://postext.dev/de/docs/document-format.md#inline-formatierung)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Schriften vor dem Layout](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Das Raster bewusst verlassen](https://postext.dev/de/docs/architecture.md#rasterbrechende-elemente)
- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Abschnittsgeometrie](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`tableStyle`](https://postext.dev/de/docs/configuration-resources.md#tabellenstil), [`toc`](https://postext.dev/de/docs/configuration-notes-references.md#inhaltsverzeichnis), [`unorderedLists`](https://postext.dev/de/docs/configuration-text.md#aufzählungen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), [`parseTSV`](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)

**Schriften**

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

## Zubereitung

### 1 · Sprache und Titel des Dokuments

```js
// script.js, Zeilen 126–127
  locale: LANG, // → /Lang es, and "Figura", "Tabla" in the captions and the citations
  // /Title and /Author come from the frontmatter's title and author
```

postext-pdf schreibt die Sprache der Tags aus `locale`, hier `/Lang es`, sodass ein Screenreader den Ratgeber mit spanischer Aussprache liest; ohne diese Angabe meldet die Datei `en-US`. `title` und `author` aus dem Frontmatter von `content.es.md` werden Titel und Autor des PDF, und die Datei bittet Betrachter, diesen Titel statt des Dateinamens in der Fensterleiste zu zeigen. Dieselbe `locale` gibt den Legenden und Verweisen ihre spanischen Namen: „Figura 1.1“, „tabla 1.1“. [Die kurze Antwort](https://postext.dev/de/cookbook/accessible-tagged-pdf.md#die-kurze-antwort) gibt der Abbildung und den Tabellen ihren zugänglichen Text: `altText` wird zur Alternativbeschreibung der Abbildung (`/Alt`) und zur `/Summary` jeder Tabelle, und die erste Zeile und die erste Spalte beider Tabellen werden als Kopfzellen getaggt.

### 2 · Eine Gliederung und ein Umschlagband nur mit dem Titel

```js
// script.js, Zeilen 74–104
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: [ // parity 'any': a section opens the next page, whichever side it falls on
    { 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
  ] };
```

Der Tag-Baum nimmt seine H1- und H2-Elemente aus den Markdown-Überschriften, und die Lesezeichen des PDF stammen aus derselben Liste: Umschlag, Inhaltsverzeichnis, Brief und beide Abschnitte sind Ebene 1, die Zwischentitel Ebene 2. postext-pdf taggt jedes Textelement in der Gestaltung einer Überschrift als Teil dieser Überschrift. Deshalb trägt das Band des Umschlags nur den Titel, und Vorspann und Name der Stadtverwaltung stehen als Absätze darunter. Die marineblauen Bänder, die Bordsteinkante und die Reihe der Tonnen sind Gestaltungselemente, und das PDF markiert Gestaltungskästen und -bilder als Artefakte; sie brauchen also keinen Alternativtext, und ein Screenreader überspringt sie.

### 3 · Ein Inhaltsverzeichnis mit Links

```js
// script.js, Zeilen 108–113
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 page numbers, and the leaders with them, in the text face
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
```

`:::toc` listet die H1 und H2 mit den Seitenzahlen, die sie drucken, und im PDF verlinkt jede seiner elf Zeilen auf den Kopf ihrer Seite. Die `:ref`-Verweise „figura 1.1“ und „tabla 1.1“ auf Seite 3 und „tabla 2.1“ auf Seite 4 verlinken auf die Abbildung und die Tabellen. Die Seitenzahlen stehen in der Textschrift, Atkinson Hyperlegible Next Bold, und die Punktlinien übernehmen die Schrift der Zahlen, nicht die Public Sans der Einträge.

### 4 · Die Lesereihenfolge, auf die Seite gezeichnet

```js
// script.js, Zeilen 299–319
// An opener band's title, then each column from top to bottom; a float is read after the text
// that cites it, wherever it is painted. 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
  const flow = blocks.filter((b) => !b.hidden);
  for (const float of page.floats ?? []) { // after the last block up to its place in the source
    flow.splice(flow.findLastIndex((b) => b.contentIndex <= float.contentIndex) + 1, 0, float);
  }
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []), ...flow.map((b) => add(b, b.bbox))];
}
```

Ein Screenreader folgt den Tags. `renderToPdf` taggt zuerst den Titel im Band, dann jede Spalte von oben nach unten, und ein Gleitobjekt liest es direkt nach dem Absatz, der es zitiert, gleich wo die Seite es zeichnet. Diese Funktion spielt dieselbe Reihenfolge nach, um die Rahmen auf Seite 3 zu nummerieren, und der Strukturbaum des PDF listet dieselben vierzehn Elemente in derselben Reihenfolge. Die Abbildung und beide Tabellen stehen im Textfluss, mit `placement: { position: 'here' }` und einer Zeile `::resource`, sodass auch sehende Leser jede von ihnen unter dem Satz finden, der sie zitiert. Der Absatz über Glas, der in die zweite Spalte weiterläuft, behält eine einzige Nummer, 9, weil das PDF beide Teile als einen Absatz taggt.

### 5 · Farben nach Kontrastverhältnis gewählt

```js
// script.js, Zeilen 15–34
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',
};
// every colour is linked to its palette entry by id
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 prüft keine Farben, doch WCAG 2.2 verlangt 4,5:1 zwischen Text und Hintergrund. Die Textfarbe erreicht 15,6:1 auf Weiß, weiße Schrift 12,5:1 auf den marineblauen Bändern und das Grau der Fußzeile 7,0:1. Das Gelb kommt auf Weiß nur auf 1,8:1, deshalb erscheint es nur auf Marineblau (6,9:1) und als Fläche. Die Piktogramme folgen der WCAG-Schwelle von 3:1 für Grafiken: weiß, wo Weiß sie gegen die Tonne erreicht, in Textfarbe auf der gelben. `cell()` schreibt den Namen jeder Tonne neben ihr Farbfeld und gibt dem Wertstoffhof ein leeres Quadrat, sodass keine Anweisung davon abhängt, Farben unterscheiden zu können.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: https://github.com/drnachio/postext/tree/main/cookbook/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.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, 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',
};
// every colour is linked to its palette entry by id
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',
  ...style, placement }); // a heading's design wraps its text; the footer clips its own

// #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' }, // set under the sentence that cites it
  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: [ // parity 'any': a section opens the next page, whichever side it falls on
    { 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 page numbers, and the leaders with them, in the text face
  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 = () => ({
  // #region identity: the language the PDF declares, and captions in that language
  locale: LANG, // → /Lang es, and "Figura", "Tabla" in the captions and the citations
  // /Title and /Author come from the frontmatter's title and author
  // #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 right
  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 renderToPdf writes for a page
// An opener band's title, then each column from top to bottom; a float is read after the text
// that cites it, wherever it is painted. 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
  const flow = blocks.filter((b) => !b.hidden);
  for (const float of page.floats ?? []) { // after the last block up to its place in the source
    flow.splice(flow.findLastIndex((b) => b.contentIndex <= float.contentIndex) + 1, 0, float);
  }
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []), ...flow.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; 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 loadSvg('contenedores.svg', binsSvg(FIGURE, 23));
await loadSvg('calle.svg', binsSvg(STREET, 36));
const content = { markdown, resources: resources() };
const doc = await buildDocumentWithFonts(content, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

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

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

// ─── Kit · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

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

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Die Abbildung gleiten lassen

Entfernen Sie die Platzierung, und Abbildung 1.1 gleitet an den Fuß der ersten Spalte von Seite 3. Das PDF liest sie weiterhin nach dem Absatz, der sie zitiert, deshalb behält die Überlagerung ihre Nummer 3, und die Reihenfolge der Tags ändert sich nicht.

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

## Häufige Fehler

- **Ein Überschriftenformat erbt den Seitenumbruch seiner Ebene.** Ein Eintrag in headingStyles übernimmt jedes Feld, das er auslässt, von seiner Überschriftenebene, breakBefore eingeschlossen. Ein Inhaltsverzeichnis oder Impressum, das nach einem :::pagebreak über eine H1 gestaltet wird, erbt die Parität 'odd' und landet hinter einer Vakatseite. Geben Sie einem solchen Format breakBefore: { enabled: false }.
- **Fontsource-Dateien decken Buchstaben ab, keine Symbole.** Das Kit bettet die latin-Datei einer Schrift von Fontsource ein, dazu ihre latin-ext-Datei für č, ł, † und ihre greek-Datei für α, χ (bei einer Familie, die eine mitliefert: Lora und Gelasio tun es nicht), wenn der Text diese Zeichen setzt, auf dem Bildschirm und im PDF. Symbole wie →, ≈, ✓ und ★ stehen in keiner dieser Dateien und fehlen im PDF: zeichnen Sie sie, setzen Sie sie als Mathematik oder wählen Sie eine Schrift, deren Dateien sie enthalten.

Der Pen kann sein eigenes PDF nicht validieren, weil veraPDF außerhalb des Browsers läuft. Laden Sie die Datei herunter und führen Sie `verapdf -f ua1 accessible-tagged-pdf.pdf` aus; die für diese Seite erfasste Kopie besteht die Prüfung. Ob ein Alternativtext sein Bild beschreibt, kann die Prüfung nicht erkennen, hören Sie sich den Ratgeber also auch mit einem Screenreader an.

Das Impressum druckt die Webadresse vollständig aus, weil der Ratgeber für postext 1.4.1 geschrieben wurde, wo ein Markdown-Link seine Wörter druckte und dem PDF keinen Link hinzufügte. Seit postext 1.10 ist ein Link `[text](url)` im PDF aktiv, getaggt als Element `Link`, dessen Text die verlinkten Wörter sind.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: Atkinson Hyperlegible Next (OFL-1.1), Atkinson Hyperlegible Mono (OFL-1.1), Public Sans (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 024 · Druckfertiges PDF: Anschnitt, Schnittmarken und PDF/X](https://postext.dev/de/cookbook/print-ready-pdf.md): Ein Ausstellungsfaltblatt mit 3 mm Anschnitt und Schnittmarken, exportiert als PDF/X-4 auf FOGRA51, mit Druckvorlagen und sauberem Preflight. · Stufe 3 (Fortgeschritten) · Einzelblätter und Akzidenzen
- [Nº 025 · Ein echtes PDF mit denselben eingebetteten Schriften](https://postext.dev/de/cookbook/pdf-with-embedded-fonts.md): Ein Konzertprogramm als PDF exportiert. Jede Schrift wird einmal geladen, für FontFace und für das PDF, und jede Überschrift wird zu einem Lesezeichen. · Stufe 2 (Mittel) · Einzelblätter und Akzidenzen
- [Nº 006 · Titelei mit römischen Paginas, dann Seite 1](https://postext.dev/de/cookbook/front-matter-roman-to-arabic.md): Umschlag und Titelei sind nicht nummerierte Überschriften, römisch in Kleinbuchstaben gezählt; :::numbering beginnt rechts beim Roman wieder bei 1. · Stufe 3 (Fortgeschritten) · Belletristik, Drama und Prosa
