# Druckfertiges PDF: Anschnitt, Schnittmarken und PDF/X

> Ein Ausstellungsfaltblatt mit 3 mm Anschnitt und Schnittmarken, exportiert als PDF/X-4 auf FOGRA51, mit Druckvorlagen und sauberem Preflight.

- HTML-Fassung: https://postext.dev/de/cookbook/print-ready-pdf
- Rezept Nº 024 · Ausgabe und Integration · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, PDF
- Gattungen: Einzelblätter und Akzidenzen
- 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/print-ready-pdf/es/p01.webp?v=166a8dd3), [2](https://postext.dev/cookbook/print-ready-pdf/es/p02.webp?v=166a8dd3), [3](https://postext.dev/cookbook/print-ready-pdf/es/p03.webp?v=166a8dd3), [4](https://postext.dev/cookbook/print-ready-pdf/es/p04.webp?v=166a8dd3)
- PDF: https://postext.dev/cookbook/print-ready-pdf/es/print-ready-pdf.pdf?v=166a8dd3
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=print-ready-pdf&lang=es (.postext: https://postext.dev/cookbook/print-ready-pdf/es/print-ready-pdf.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/print-ready-pdf.md), [es](https://postext.dev/es/cookbook/print-ready-pdf.md), [ca](https://postext.dev/ca/cookbook/print-ready-pdf.md), [fr](https://postext.dev/fr/cookbook/print-ready-pdf.md), [it](https://postext.dev/it/cookbook/print-ready-pdf.md), [pt](https://postext.dev/pt/cookbook/print-ready-pdf.md), [zh](https://postext.dev/zh/cookbook/print-ready-pdf.md), [ja](https://postext.dev/ja/cookbook/print-ready-pdf.md), [ar](https://postext.dev/ar/cookbook/print-ready-pdf.md)

## Kurz gesagt

Ein Faltblatt für eine Kartenausstellung, bereit für die Druckerei. Es zeigt, wie man zusätzlichen Raum über den Seitenrand hinaus und Schnittmarken anlegt und ein PDF in den Farben der Druckmaschinen speichert.

## Was Sie setzen

Das Saalblatt für *Cartografías imaginarias*, eine erfundene Ausstellung mit Karten erfundener Orte, so wie es in die Druckerei geht, in beiden Ausgaben dieser Seite auf Spanisch. Jede Seite von 170 × 227 mm liegt auf einem um 22 mm größeren Bogen, der Anschnitt und Schnittmarken trägt. Auf dem Umschlag kreuzt eine orange Route einen Archipel in abgestuften Grüntönen und läuft über das Endformat hinaus in den Anschnitt. Innen öffnen sich vier Säle in Karla im Flattersatz unter Wegpunkt-Dachzeilen, und ein oranger Reiter trägt die Pagina über den Außenrand hinaus; die Rückseite setzt einen Grundriss über ein Band aus Relief. Der Pen exportiert die Druckdatei, ein PDF/X-4, separiert für FOGRA51 (die Bedingung PSO Coated v3 des gestrichenen Papiers mit 150 g), mit Schriften, Lesezeichen und Seitenlabels, dazu einen Graustufenproof, und führt den Preflight am Layout aus, bevor er sie anbietet. In der Druckdatei kommen die Karten und der Grundriss aus Druckvorlagen in handverlesenen Farbaufbauten, und Schrift, Reiter und Wegpunktringe verwenden dieselben Farben über eine in CMYK angelegte Palette.

**Dieses Rezept beantwortet:**

- Wie exportiere ich druckfertiges PDF/X mit Anschnitt, Schnittmarken, ICC-Profil, Lesezeichen und Seitenlabels?
- Wie exportiere ich im Browser ein echtes PDF mit eingebetteten Schriften?
- Wie füge ich auf jeder Seite ein Wasserzeichen, eine Hintergrundtönung oder ein Schmuckbild ein?

## Die kurze Antwort

```js
// script.js, Zeilen 46–76
const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
// Hook-up: config().page.cutLines. A mark starts markOffset outside the trim (3 mm by default)
// and never inside the bleed, and runs 5 mm (markLength). Each page grows by doc.trimOffset
// a side, bleed + offset + mark: 11 mm here.
const cutLines = { enabled: true, bleed: mm(BLEED) };

// Hook-up: config().print. PDF/X-4 for the press on FOGRA51 (PSO Coated v3, the leaflet's
// 150 g coated matte): every colour separated through the profile (the authored ones as
// written), 100 % K text overprinting, the output intent, TrimBox and BleedBox on each page.
// The 7.5 pt bold labels in forest hold in four inks on coated stock: the small-text check
// starts at 7 pt instead of 9.
const PRINT = { standard: 'pdfx4', outputProfile: 'fogra51', preflight: { smallTextSize: pt(7) } };
// Where the profiles are fetched from: postext.dev serves postext's icc/ folder at /icc/ (any
// copy of it works, and so does the npm CDN's postext/icc/).
const PROFILES = 'https://postext.dev/icc/';

// `masters` holds print-master bytes by fileId (section 2 writes them). The proof is an
// ordinary greyscale PDF: no PDF/X, which would force CMYK.
function pressPdf(doc, kind, masters) {
  const press = kind === 'press';
  return renderToPdf(doc, {
    fontProvider: fontsourceProvider, // Fontsource's files; TrueType faces are subset
    // The press file takes config().print, its profile fetched from PROFILES
    // (outputProfile: the .icc bytes, to hand it over yourself).
    profileBaseUrl: PROFILES,
    ...(press ? {} : { print: { standard: 'none' }, colorSpace: 'grayscale' }),
    // renderToPdf asks for a drawing's master (svg.pdfFileId) first, then for its SVG. The
    // proof answers the SVG only, which its grey conversion can reach; a master stays in CMYK.
    resourceBytes: (fileId) => (press && masters.get(fileId)) || imageBytes(fileId),
  }); // bookmarks (from the headings) and /PageLabels (from the folios) come by default
}
```

## Zutaten

**Zeigt**

- [Anschnitt und Schnittmarken](https://postext.dev/de/docs/configuration-page-layout.md#schnittmarken): Vergrößert den Bogen um den Anschnitt, zeichnet Schnittmarken an den Ecken in Canvas und PDF und lässt Gestaltungselemente am Anschnitt verankern.
- [PDFs in CMYK und Graustufen](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#pdf-erzeugung-konfiguration): Farben tragen ein Modell (hex, rgb, cmyk, hsl), und das PDF lässt sich für die Druckerei oder einen Proof auf CMYK oder Graustufen zwingen.
- [Druckvorlagen für SVG](https://postext.dev/de/docs/sandbox.md#presetjson): Ein Vektor-PDF, das das PDF-Backend unverändert anstelle einer SVG-Abbildung einbettet, für Grafiken genau wie in der Datei des Illustrators.

**Verwendet außerdem**

- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [PDF-Lesezeichen](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#pdf-erzeugung-konfiguration)
- [Anschnittbänder und Daumenregister](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Abschnittsgeometrie](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`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), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`print`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#druckvorstufe-konfiguration), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**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), [`defaultResourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), `loadOutputProfile`, `outputTransform`, `preflightDocument`, [`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**

- Karla (OFL-1.1), Space Grotesk (OFL-1.1), Space Mono (OFL-1.1)

## Zubereitung

### 1 · Bildschirmfarben nach ihren Druckplatten wählen

```js
// script.js, Zeilen 19–42
const palette = {
  ink: '#161616', // text: a neutral grey prints on the black plate alone (K95 on FOGRA51)
  muted: '#666666', // the running heads, on black alone for the same reason (K66)
  forest: '#0b3d2e', // the sea, the kickers, the back band
  signal: '#ff6626', // route, waypoints, tab
  sage: '#9fb8a8', // high ground; small type on forest
  paper: '#f4f1ea', // type on forest: reversed out, it prints no ink at all
};
// The colours authored in CMYK (%), the same builds as the print masters (section 2), so the
// type, the tab and the drawings share their inks. Without them the profile would separate
// the forest as C90 M43 Y71 K62 and the orange as C0 M70 Y90 K0, a shade off the route.
const AUTHORED = { forest: [100, 45, 80, 55], signal: [0, 60, 85, 0], sage: [40, 20, 35, 0],
  paper: [0, 0, 0, 0] };
const cmyk = ([c, m, y, k]) => ({ c, m, y, k });
const value = (id) => (AUTHORED[id]
  ? { hex: palette[id], model: 'cmyk', cmyk: cmyk(AUTHORED[id]) }
  : { hex: palette[id], model: 'hex' });
// col(id): a palette-linked colour: the hex for the screen and, where authored, its build.
const col = (id) => ({ ...value(id), paletteId: id });
const colorPalette = [
  ...Object.keys(palette).map((id) => ({ id, name: id, value: value(id) })),
  // The engine's defaults link to 'main-color': point it at the forest, so nothing prints blue.
  { id: 'main-color', name: 'forest (defaults)', value: { hex: palette.forest, model: 'hex' } },
];
```

`print: { standard: 'pdfx4', outputProfile: 'fogra51' }` in der Konfiguration separiert jede Farbe über das FOGRA51-Profil, das mit postext ausgeliefert wird. Neutrale Grautöne drucken allein auf der Schwarzplatte, ihr K so gewählt, dass der Ton passt: `#161616` wird zu K95 und das `#666666` der Kolumnentitel zu K66, und Text in 100 % K überdruckt. Das Profil druckte das Waldgrün als C90 M43 Y71 K62 und das Orange als C0 M70 Y90 K0, eine Nuance neben der Route der Druckvorlagen (Schritt 5), deshalb sind diese beiden, das Salbeigrün und die Papierfarbe in CMYK angelegt: `ColorValue.cmyk` enthält den Farbaufbau, der Hexwert bleibt für den Bildschirm, und jede mit der Palette verknüpfte Verwendung druckt den Aufbau so, wie er notiert ist. Die Papierfarbe ist C0 M0 Y0 K0: Schrift, die aus dem Waldgrün ausgespart ist, druckt keine Farbe, sodass keine Platte sie verschieben kann.

### 2 · Ein Daumenregister, das die Schneidemaschine übersteht

```js
// script.js, Zeilen 86–107
const TAB = { w: 10, h: 24 }; // the tab as it will be trimmed, mm; its foot is level with the text
const tab = (parity) => {
  const edge = parity === 'even' ? 'bottom-left' : 'bottom-right'; // the fore-edge
  const from = (to, y) => ({ anchor: { to, edge }, offset: { y: mm(y) } });
  return [
    // The box hangs from the bleed frame and BLEED of its width is trimmed off, so a cut that
    // lands a millimetre outside the trim still leaves orange at the edge.
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('signal') },
      placement: { ...from('bleed', -(FOOT + BLEED)), size: { width: mm(TAB.w + BLEED),
        height: mm(TAB.h) } } },
    // The folio hangs from the trim frame ('page'), so it centres on the part left after the cut.
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...label, align: 'center',
      fontSize: pt(11), letterSpacing: pt(0), color: col('ink'),
      placement: { ...from('page', -FOOT), size: { width: mm(TAB.w), height: mm(TAB.h) } } },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on the covers
};
const head = (parity, content) => ({ kind: 'text', id: `head-${parity}`, content, ...label,
  color: col('muted'), parity, pages: 'body',
  placement: { anchor: { to: 'container', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { y: mm(11) } } });
const header = { elements: [head('even', '{title}'), head('odd', '{subtitle}')] };
const footer = { elements: [...tab('even'), ...tab('odd')] };
```

Der Kasten des Reiters hängt am Rahmen des Anschnitts und ist um `BLEED` breiter als der fertige Reiter, sodass ein Schnitt, der einen Millimeter außerhalb des Endformats landet, am Rand trotzdem Orange lässt; die Pagina hängt am Rahmen der Seite, also am Endformat, und zentriert sich auf den verbleibenden 10 mm. `parity` wählt die Seiten, auf denen ein Element erscheint, und `edge` wechselt mit, um den Außenrand zu erreichen, während `pages: 'body'` den Reiter und die Kolumnentitel (`{title}` und `{subtitle}` aus dem Frontmatter) von beiden Umschlagseiten fernhält, die als Auftaktseiten zählen, weil jede mit einer Überschrift beginnt, die über die ganze Seitenbreite reicht oder auf eine neue Seite umbricht. Jedes Element der Kopf- oder Fußzeile wiederholt sich auf diese Weise, sodass ein `image` mit `parity` und `pages` ein Wasserzeichen oder ein Schmuckbild auf jede Seite setzt; Plätze malen über den Text, und eine Tönung dahinter gehört in `page.backgroundColor`.

### 3 · Bilder bis in den Anschnitt, Schrift innerhalb des Endformats

```js
// script.js, Zeilen 124–170
const BAND = 44; // mm from the trim foot to the top of the back band
const onForest = { color: col('paper'), align: 'left', overflow: 'wrap' };
const art = (resourceId, edge) => ({ kind: 'image', id: resourceId, resourceId,
  placement: { anchor: { to: 'bleed', edge }, size: { width: 'fill' } } }); // height: its ratio
// The map is as tall as the sheet and the cover reserves down to its foot, so the lead
// starts on page 2. span: 'page' makes the cover an opener, so furniture set to
// pages: 'body' skips it.
const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true,
  slot: { elements: [ // paint order: the map first, the type on top
    art('cubierta', 'top-left'),
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('sage'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(50), lineHeight: 0.98, ...onForest,
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(4) },
        size: { width: mm(132) } } },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Karla', fontSize: pt(13),
      ...onForest, placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
  ] } },
  footer: { elements: [ // the cover's own foot: dates and place, over open sea
    { kind: 'text', id: 'dates', content: '{attr.fechas}', ...label, color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'bottom-left' }, offset: { y: mm(-15) } } },
    { kind: 'text', id: 'place', content: '{attr.lugar}', ...label, color: col('sage'),
      placement: { anchor: { to: '#dates', edge: 'below' }, offset: { y: mm(1.6) } } },
  ] } };
const back = { id: 'back', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: room('{attr.kicker}'),
  margins: { bottom: mm(BAND + 8) }, // the text stops 8 mm above the band
  footer: { elements: [
    art('banda', 'bottom-left'),
    { kind: 'text', id: 'venue', content: '{attr.lugar}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(20), ...onForest, // on the band, level with the text edge
      placement: { anchor: { to: '#banda', edge: 'align-top' }, offset: { x: mm(BLEED + OUTER),
        y: mm(6) } } },
    { kind: 'text', id: 'address', content: '{attr.direccion}', fontFamily: 'Karla',
      fontSize: pt(10), ...onForest,
      placement: { anchor: { to: '#venue', edge: 'below' }, offset: { y: mm(1.5) } } },
    { kind: 'text', id: 'when', content: '{attr.fechas}', ...label, color: col('sage'),
      placement: { anchor: { to: '#address', edge: 'below' }, offset: { y: mm(4) } } },
    { kind: 'text', id: 'colophon', content: '{attr.colofon}', ...label, fontWeight: 400,
      // 6.3 pt in sage would be three inks on a four-ink band: reversed to paper, it prints
      // no ink and no plate can shift it (the preflight's small-text check).
      fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
      color: col('paper'), overflow: 'wrap',
      placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(OUTER),
        y: mm(-6) }, size: { width: mm(78) } } }, // 57 monospaced characters a line
  ] } };
```

`cutLines` fügt an jeder Kante der Seite Anschnitt, Markenabstand und die 5-mm-Marke hinzu, hier 11 mm. Der Abstand beträgt standardmäßig 3 mm, so viel wie dieser Anschnitt, deshalb beginnen die Marken an der Kante des Anschnitts und laufen nach außen; bei einem breiteren Anschnitt begännen sie weiterhin an seiner Kante, nie innerhalb. Ein Bildelement, das mit `width: 'fill'` am Anschnitt verankert ist, nimmt die Breite dieses Rahmens an und behält seine eigenen Proportionen: Die Umschlagkarte ist in 176 × 233 mm gezeichnet, dem Endformat plus 3 mm je Seite, sodass sie die Kante des Anschnitts erreicht, wo die Schnittmarken beginnen, während die Schrift am Textbereich hängt, deutlich innerhalb des Endformats. Die Rückseite ist ein Überschriftenformat, dessen Fußzeile die des Dokuments ersetzt, mit einem Band aus demselben Generator, verankert am Fuß des Anschnitts, und einem unteren Rand, der den Text 8 mm darüber enden lässt.

### 4 · Überschriften, die zu Lesezeichen werden

```js
// script.js, Zeilen 190–201
  headings: {
    // A designed heading keeps its own text, hidden, for the bookmarks and the tags. Set it in
    // a face the pages already load, or the PDF embeds Open Sans for that text alone.
    fontFamily: 'Space Grotesk',
    levels: [
      // Rooms follow on: the H1's page break is off. The back cover breaks through its style,
      // and the front one fills its page. The template numbers kicker and bookmark: "Sala 1
      // Una isla en ninguna parte".
      { level: 1, numberingTemplate: 'Sala {1}', breakBefore: { enabled: false },
        marginTop: pt(LEAD), advancedDesign: room('{number} · {attr.fecha}') },
    ],
  },
```

`renderToPdf` baut die Lesezeichen aus den Überschriften, deshalb ist jeder Saal eine Überschrift der Ebene 1, die ohne Seitenumbruch folgt, und `numberingTemplate` gibt der Dachzeile ihre `{number}` und dem Lesezeichen sein Präfix: Das PDF listet den Umschlag, „Sala 1 Una isla en ninguna parte“ bis „Sala 4 La isla del tesoro“ und die praktischen Hinweise. Eine gestaltete Überschrift verbirgt ihren eigenen Text, behält ihn aber für Lesezeichen und Tags; ohne `fontFamily` wird dieser verborgene Text in Open Sans 700 gesetzt, die die Seiten laden und das PDF allein für diesen Text einbetten würde. Die Seitenlabels folgen `page.pageNumbering`, auf dem Standardwert belassen, dezimal ab 1, sodass Seite 3 in einem PDF-Betrachter die Seite ist, deren Reiter 3 zeigt.

### 5 · Druckvorlagen in exakten Farben

```js
// script.js, Zeilen 394–426
// C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a
// proof. The palette authors forest, signal, sage and paper with these same builds, so the
// route matches the tab and the kickers match the sea.
const INKS = { forest: [100, 45, 80, 55], shallows: [100, 50, 85, 40], coast: [90, 50, 85, 20],
  lowland: [80, 40, 75, 5], upland: [50, 15, 45, 20], sage: [40, 20, 35, 0],
  summit: [10, 5, 10, 0], tint: [5, 0, 5, 5], signal: [0, 60, 85, 0], paper: [0, 0, 0, 0] };
function pdfPage(w, h, shapes) { // w, h in mm; the content stream flips y to match the SVG
  const s = 72 / 25.4; // pt per mm
  const ink = (id, op) => `${INKS[id].map((v) => v / 100).join(' ')} ${op}`;
  const ops = (d) => d.replace(/([MLCZ])([^MLCZ]*)/g,
    (_, c, v) => `${v.trim()} ${{ M: 'm', L: 'l', C: 'c', Z: 'h' }[c]} `.trimStart());
  const body = shapes.map(({ d, fill, stroke, width, dash }) => [
    'q', fill && ink(fill, 'k'), stroke && ink(stroke, 'K'), stroke && `${width} w 1 J 1 j`,
    dash && `[${dash.join(' ')}] 0 d`, ops(d), fill ? 'f' : 'S', 'Q',
  ].filter(Boolean).join(' ')).join('\n');
  const stream = `${s} 0 0 ${-s} 0 ${h * s} cm\n${body}`;
  const objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(w * s)} ${n(h * s)}] /Contents 4 0 R >>`,
    `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`];
  let pdf = '%PDF-1.4\n';
  const offsets = objects.map((object, i) => {
    const offset = pdf.length;
    pdf += `${i + 1} 0 obj\n${object}\nendobj\n`;
    return offset;
  });
  const xref = pdf.length;
  pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
    + offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
    + `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return new TextEncoder().encode(pdf); // ASCII only, so string length = byte length
}
const masters = new Map(Object.entries(DRAWINGS) // by fileId: cubierta.pdf, banda.pdf, plano.pdf
  .map(([id, [size, shapes]]) => [`${id}.pdf`, pdfPage(...size, shapes)]));
```

Jede Zeichnung ist einmal definiert, als Pfade. Die Region „art“ macht daraus das SVG, das der Canvas malt, und diese Region schreibt dieselben Pfade in ein einseitiges CMYK-PDF mit Farbaufbauten, die so gewählt sind, dass ein Proof dem Bildschirm entspricht, was das Meer auf C100 M45 Y80 K55 setzt, das Waldgrün der Palette. Jede Ressource nennt ihre Druckvorlage in `svg.pdfFileId`; `renderToPdf` fragt nach dieser ID, `pressPdf()` in der kurzen Antwort antwortet mit den Bytes der Druckvorlage, und postext-pdf bettet diese Seite unverändert ein, als Vektoren in den eigenen Farben der Vorlage. Das gilt für den Grundriss, eine Abbildung, und für die Umschlagkarte und das Band der Rückseite, die Gestaltungsbilder sind. Der Graustufenproof bekommt stattdessen die SVGs, weil eine eingebettete Druckvorlage nie umgerechnet wird und farbig bliebe.

## 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/print-ready-pdf

### script.js

```js
// ═══ Postext Cookbook · Nº 024 · Print-ready PDF: bleed, crop marks and CMYK ═══════════
// https://postext.dev/en/cookbook/print-ready-pdf
// Code: MIT · Text: original (CC BY 4.0) · Maps: generated in code (CC BY 4.0)
// Fonts: Karla, Space Grotesk, Space Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// An exhibition leaflet set up for the press: bleed and crop marks on every page, and a
// PDF/X-4 file separated for FOGRA51 that embeds its fonts and takes the maps and the floor
// plan from print masters, checked by the preflight before it is offered.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
  preflightDocument, loadOutputProfile, outputTransform,
} 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 = 'print-ready-pdf';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: screen colours, and for four of them the inks the press prints them in
const palette = {
  ink: '#161616', // text: a neutral grey prints on the black plate alone (K95 on FOGRA51)
  muted: '#666666', // the running heads, on black alone for the same reason (K66)
  forest: '#0b3d2e', // the sea, the kickers, the back band
  signal: '#ff6626', // route, waypoints, tab
  sage: '#9fb8a8', // high ground; small type on forest
  paper: '#f4f1ea', // type on forest: reversed out, it prints no ink at all
};
// The colours authored in CMYK (%), the same builds as the print masters (section 2), so the
// type, the tab and the drawings share their inks. Without them the profile would separate
// the forest as C90 M43 Y71 K62 and the orange as C0 M70 Y90 K0, a shade off the route.
const AUTHORED = { forest: [100, 45, 80, 55], signal: [0, 60, 85, 0], sage: [40, 20, 35, 0],
  paper: [0, 0, 0, 0] };
const cmyk = ([c, m, y, k]) => ({ c, m, y, k });
const value = (id) => (AUTHORED[id]
  ? { hex: palette[id], model: 'cmyk', cmyk: cmyk(AUTHORED[id]) }
  : { hex: palette[id], model: 'hex' });
// col(id): a palette-linked colour: the hex for the screen and, where authored, its build.
const col = (id) => ({ ...value(id), paletteId: id });
const colorPalette = [
  ...Object.keys(palette).map((id) => ({ id, name: id, value: value(id) })),
  // The engine's defaults link to 'main-color': point it at the forest, so nothing prints blue.
  { id: 'main-color', name: 'forest (defaults)', value: { hex: palette.forest, model: 'hex' } },
];
// #endregion

// #region answer: bleed and crop marks on every page; a CMYK PDF that swaps in print masters
const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
// Hook-up: config().page.cutLines. A mark starts markOffset outside the trim (3 mm by default)
// and never inside the bleed, and runs 5 mm (markLength). Each page grows by doc.trimOffset
// a side, bleed + offset + mark: 11 mm here.
const cutLines = { enabled: true, bleed: mm(BLEED) };

// Hook-up: config().print. PDF/X-4 for the press on FOGRA51 (PSO Coated v3, the leaflet's
// 150 g coated matte): every colour separated through the profile (the authored ones as
// written), 100 % K text overprinting, the output intent, TrimBox and BleedBox on each page.
// The 7.5 pt bold labels in forest hold in four inks on coated stock: the small-text check
// starts at 7 pt instead of 9.
const PRINT = { standard: 'pdfx4', outputProfile: 'fogra51', preflight: { smallTextSize: pt(7) } };
// Where the profiles are fetched from: postext.dev serves postext's icc/ folder at /icc/ (any
// copy of it works, and so does the npm CDN's postext/icc/).
const PROFILES = 'https://postext.dev/icc/';

// `masters` holds print-master bytes by fileId (section 2 writes them). The proof is an
// ordinary greyscale PDF: no PDF/X, which would force CMYK.
function pressPdf(doc, kind, masters) {
  const press = kind === 'press';
  return renderToPdf(doc, {
    fontProvider: fontsourceProvider, // Fontsource's files; TrueType faces are subset
    // The press file takes config().print, its profile fetched from PROFILES
    // (outputProfile: the .icc bytes, to hand it over yourself).
    profileBaseUrl: PROFILES,
    ...(press ? {} : { print: { standard: 'none' }, colorSpace: 'grayscale' }),
    // renderToPdf asks for a drawing's master (svg.pdfFileId) first, then for its SVG. The
    // proof answers the SVG only, which its grey conversion can reach; a master stays in CMYK.
    resourceBytes: (fileId) => (press && masters.get(fileId)) || imageBytes(fileId),
  }); // bookmarks (from the headings) and /PageLabels (from the folios) come by default
}
// #endregion

const TRIM = [170, 227]; // mm: the leaflet as it leaves the guillotine
const [TOP, FOOT, INNER, OUTER] = [20, 22, 18, 34]; // margins, mm, mirrored; the tab is outside
const LEAD = 13.6; // body leading, pt
const label = { fontFamily: 'Space Mono', fontWeight: 700, fontSize: pt(7.5),
  letterSpacing: pt(1.3), textTransform: 'uppercase', align: 'left' }; // default: centred

// #region tab: a thumb tab off the fore-edge that carries the folio, on body pages only
const TAB = { w: 10, h: 24 }; // the tab as it will be trimmed, mm; its foot is level with the text
const tab = (parity) => {
  const edge = parity === 'even' ? 'bottom-left' : 'bottom-right'; // the fore-edge
  const from = (to, y) => ({ anchor: { to, edge }, offset: { y: mm(y) } });
  return [
    // The box hangs from the bleed frame and BLEED of its width is trimmed off, so a cut that
    // lands a millimetre outside the trim still leaves orange at the edge.
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('signal') },
      placement: { ...from('bleed', -(FOOT + BLEED)), size: { width: mm(TAB.w + BLEED),
        height: mm(TAB.h) } } },
    // The folio hangs from the trim frame ('page'), so it centres on the part left after the cut.
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...label, align: 'center',
      fontSize: pt(11), letterSpacing: pt(0), color: col('ink'),
      placement: { ...from('page', -FOOT), size: { width: mm(TAB.w), height: mm(TAB.h) } } },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on the covers
};
const head = (parity, content) => ({ kind: 'text', id: `head-${parity}`, content, ...label,
  color: col('muted'), parity, pages: 'body',
  placement: { anchor: { to: 'container', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { y: mm(11) } } });
const header = { elements: [head('even', '{title}'), head('odd', '{subtitle}')] };
const footer = { elements: [...tab('even'), ...tab('odd')] };
// #endregion

const room = (kicker) => ({ enabled: true, slot: { elements: [ // a waypoint, kicker and title
  { kind: 'box', id: 'stop', style: { backgroundColor: col('paper'), borderColor: col('signal'),
    borderWidth: mm(0.9), borderRadius: mm(1.7) },
  placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(3.4),
    height: mm(3.4) } } },
  { kind: 'text', id: 'kicker', content: kicker, ...label, color: col('forest'),
    placement: { anchor: { to: '#stop', edge: 'right-of' }, offset: { x: mm(2.2) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
    fontWeight: 700, fontSize: pt(17), lineHeight: 1.08, color: col('ink'), align: 'left',
    placement: { anchor: { to: '#stop', edge: 'below' }, offset: { y: mm(1.2) },
      size: { width: 'fill' } } },
] } });

// #region covers: the front art runs bleed to bleed; the back band bleeds on three sides
const BAND = 44; // mm from the trim foot to the top of the back band
const onForest = { color: col('paper'), align: 'left', overflow: 'wrap' };
const art = (resourceId, edge) => ({ kind: 'image', id: resourceId, resourceId,
  placement: { anchor: { to: 'bleed', edge }, size: { width: 'fill' } } }); // height: its ratio
// The map is as tall as the sheet and the cover reserves down to its foot, so the lead
// starts on page 2. span: 'page' makes the cover an opener, so furniture set to
// pages: 'body' skips it.
const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true,
  slot: { elements: [ // paint order: the map first, the type on top
    art('cubierta', 'top-left'),
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('sage'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(50), lineHeight: 0.98, ...onForest,
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(4) },
        size: { width: mm(132) } } },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Karla', fontSize: pt(13),
      ...onForest, placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
  ] } },
  footer: { elements: [ // the cover's own foot: dates and place, over open sea
    { kind: 'text', id: 'dates', content: '{attr.fechas}', ...label, color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'bottom-left' }, offset: { y: mm(-15) } } },
    { kind: 'text', id: 'place', content: '{attr.lugar}', ...label, color: col('sage'),
      placement: { anchor: { to: '#dates', edge: 'below' }, offset: { y: mm(1.6) } } },
  ] } };
const back = { id: 'back', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: room('{attr.kicker}'),
  margins: { bottom: mm(BAND + 8) }, // the text stops 8 mm above the band
  footer: { elements: [
    art('banda', 'bottom-left'),
    { kind: 'text', id: 'venue', content: '{attr.lugar}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(20), ...onForest, // on the band, level with the text edge
      placement: { anchor: { to: '#banda', edge: 'align-top' }, offset: { x: mm(BLEED + OUTER),
        y: mm(6) } } },
    { kind: 'text', id: 'address', content: '{attr.direccion}', fontFamily: 'Karla',
      fontSize: pt(10), ...onForest,
      placement: { anchor: { to: '#venue', edge: 'below' }, offset: { y: mm(1.5) } } },
    { kind: 'text', id: 'when', content: '{attr.fechas}', ...label, color: col('sage'),
      placement: { anchor: { to: '#address', edge: 'below' }, offset: { y: mm(4) } } },
    { kind: 'text', id: 'colophon', content: '{attr.colofon}', ...label, fontWeight: 400,
      // 6.3 pt in sage would be three inks on a four-ink band: reversed to paper, it prints
      // no ink and no plate can shift it (the preflight's small-text check).
      fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
      color: col('paper'), overflow: 'wrap',
      placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(OUTER),
        y: mm(-6) }, size: { width: mm(78) } } }, // 57 monospaced characters a line
  ] } };
// #endregion

const config = () => ({
  locale: LANG, // the PDF's /Lang, and "Figura" in the caption
  // The built-in types in the leaflet's language, with one running count: "Figura 1".
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never' })),
  colorPalette,
  print: PRINT,
  headingStyles: [cover, back],
  page: { width: mm(TRIM[0]), height: mm(TRIM[1]), cutLines,
    margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Karla', fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('forest'),
    // Ragged, like the labels, and so not hyphenated (ragged text keeps its words whole
    // unless hyphenation.ragged asks); the 118 mm measure keeps the rag shallow.
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // #region levels: every room a numbered H1 with no page break, so each is a PDF bookmark
  headings: {
    // A designed heading keeps its own text, hidden, for the bookmarks and the tags. Set it in
    // a face the pages already load, or the PDF embeds Open Sans for that text alone.
    fontFamily: 'Space Grotesk',
    levels: [
      // Rooms follow on: the H1's page break is off. The back cover breaks through its style,
      // and the front one fills its page. The template numbers kicker and bookmark: "Sala 1
      // Una isla en ninguna parte".
      { level: 1, numberingTemplate: 'Sala {1}', breakBefore: { enabled: false },
        marginTop: pt(LEAD), advancedDesign: room('{number} · {attr.fecha}') },
    ],
  },
  // #endregion
  paragraphStyles: [
    // Only size and face change: styles inherit the rest. 'ficha' sets each room's object label.
    { id: 'lead', fontFamily: 'Space Grotesk', fontSize: pt(12), lineHeight: pt(16.5) },
    { id: 'ficha', fontFamily: 'Space Mono', fontSize: pt(7), lineHeight: pt(10.5) },
  ],
  // Captions in the label face, like the object labels; the label in forest (orange is 2.9:1).
  captionStyle: { fontFamily: 'Space Mono', fontSize: pt(7.5), labelColor: col('forest') },
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Cartografías imaginarias"
subtitle: "Mapas de lugares que nunca existieron"
author: "Biblioteca del Faro"
---

# Cartografías imaginarias {style="cover" kicker="Exposición temporal · Ala Norte" fechas="Del 14 de enero al 29 de abril de 2027" lugar="Biblioteca del Faro · Puerto Alba"}

:::paragraphs{style="lead"}
La exposición reúne en facsímil cuatro mapas de lugares inventados, impresos entre 1516 y 1883. Tres dibujan las costas de tierras que nadie ha pisado: la isla de Utopía, el país de la Ternura y la isla del tesoro. El cuarto, la carta marina de un poema de Lewis Carroll, deja el mar en blanco. Las salas siguen el orden de las fechas y se recorren en el sentido de las agujas del reloj.
:::

# Una isla en ninguna parte {fecha="1516"}

En 1516 Tomás Moro publicó en Lovaina un librito en latín sobre el mejor estado de una república y sobre una isla nueva llamada Utopía. El nombre viene del griego *ou tópos*, «ningún lugar». La isla tiene forma de media luna, doscientas millas de anchura en su parte central y cincuenta y cuatro ciudades casi idénticas. La capital, Amauroto, «la ciudad oscura», se levanta junto al Anidro, «el río sin agua». Moro disfraza de geografía un tratado político, y el grabado de la primera edición rotula en latín la capital, el nacimiento del Anidro y su desembocadura.

:::paragraphs{style="ficha"}
Tomás Moro, *Libellus… deque nova insula Utopia*. Lovaina: Dirk Martens, 1516. Facsímil. «Utopiae insulae figura», grabado de la primera edición.
:::

# El país de la Ternura {fecha="1654"}

Madeleine de Scudéry publicó en 1654 el primero de los diez tomos de *Clélie*, una novela ambientada en la Roma antigua, e incluyó en él un mapa grabado, la *Carte de Tendre*. Desde Nueva Amistad salen tres caminos hacia tres ciudades llamadas Ternura: la de la Inclinación, la de la Estima y la del Reconocimiento. El primero sigue un río y es el más rápido; los otros dos atraviesan aldeas con nombres de virtudes, como Sinceridad o Generosidad. Quien se desvía acaba en el lago de la Indiferencia, y más allá del mar Peligroso el grabado deja unas Tierras Desconocidas.

:::paragraphs{style="ficha"}
Madeleine de Scudéry, *Clélie, histoire romaine*, tomo I. París: Augustin Courbé, 1654. Facsímil. «Carte de Tendre», grabado de François Chauveau.
:::

# El océano en blanco {fecha="1876"}

En *La caza del Snark*, el poema que Lewis Carroll publicó en 1876 con el subtítulo «Una agonía en ocho cantos», el Campanero, que capitanea a ocho hombres y un castor, compra una gran carta del mar sin el menor rastro de tierra, y la tripulación la celebra porque, por fin, todos la entienden. Cuando el capitán pregunta de qué sirven los polos, los trópicos y los meridianos de Mercator, los marineros contestan que son simples signos convencionales. La carta es «un vacío perfecto y absoluto», y el libro la imprimió tal cual en el segundo canto: un rectángulo en blanco con unos pocos rótulos en el marco. El Campanero, para quien todo lo que dice tres veces es verdad, no tiene otro método para cruzar el océano que tocar la campana, y en la travesía el bauprés se confunde a veces con el timón, algo que según él ocurre a menudo en los climas tropicales.

:::paragraphs{style="ficha"}
Lewis Carroll, *The Hunting of the Snark*, con ilustraciones de Henry Holiday. Londres: Macmillan, 1876. Facsímil. Carta del océano, a tamaño real.
:::

# La isla del tesoro {fecha="1881"}

En el verano de 1881, en Braemar, en las Tierras Altas de Escocia, Robert Louis Stevenson dibujó y coloreó una isla para entretener a su hijastro, Lloyd Osbourne, de trece años. De aquel mapa salió la novela, titulada al principio *The Sea Cook*, que se publicó por entregas en la revista juvenil *Young Folks* entre octubre de 1881 y enero de 1882, firmada por un tal «capitán George North», y como libro en 1883. En 1894 contó en la revista *The Idler* que el mapa original, enviado con el manuscrito a la editorial Cassell, nunca llegó, y que tuvo que rehacerlo al revés, a partir del libro: hizo inventario de cada alusión y ajustó con un compás la isla a esos datos. El nuevo mapa se dibujó en el despacho de su padre, con ballenas y barcos, y Thomas Stevenson, ingeniero de faros, falsificó con esmero la firma del capitán Flint y las instrucciones de navegación de Billy Bones. Es el que abre, como frontispicio, la primera edición, y se expone junto al número de *The Idler* en el que Stevenson cuenta la pérdida.

:::paragraphs{style="ficha"}
Robert Louis Stevenson, *Treasure Island*. Londres: Cassell & Company, 1883. Facsímil. «My First Book: Treasure Island», en *The Idler*, 1894.
:::

# Información práctica {style="back" kicker="Visita" lugar="Biblioteca del Faro" direccion="Paseo del Muelle, 12 · Puerto Alba" fechas="Del 14 de enero al 29 de abril de 2027" colofon="Compuesto en Karla, Space Grotesk y Space Mono (SIL OFL). Textos: CC BY 4.0; mapas dibujados en código."}

El plano de la :ref{id="plano" style="full" case="lower"} traza el recorrido, que empieza y acaba en el vestíbulo.

**Horario.** De martes a sábado, de 10:00 a 14:00 y de 17:00 a 20:00; domingos y festivos, de 10:00 a 14:00. Los lunes, cerrado.

**Entrada libre.** Visitas guiadas los sábados a las 12:00, sin reserva, en grupos de hasta veinte personas.

**Taller familiar.** «Dibuja tu isla», para niños de 6 a 12 años, los domingos a las 11:00. Inscripción en la recepción.

**Accesibilidad.** Todas las salas están a pie de calle. En la recepción hay lupas y los textos de sala en letra grande.
`; // content.<lang>.md, inlined by the Cookbook

// #region art: an invented archipelago and a floor plan, drawn as paths
// Paths and flat fills only: no <marker>, filter or mask, so the PDF keeps every drawing
// vector (gotcha: svg-no-marker-filters).
const PX = 12; // declared pixels per mm. Only the ratio counts: SVG figures fill their frame
const rng = (seed) => () => { // Mulberry32: the same maps on every run
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => Math.round(v * 100) / 100;
const xy = ([x, y]) => `${n(x)} ${n(y)}`;
const spline = (pts, closed) => { // Catmull-Rom through the points, as cubic Béziers
  const last = pts.length - 1;
  const at = (i) => pts[closed ? (i + pts.length) % pts.length : Math.max(0, Math.min(last, i))];
  const segs = pts.slice(0, closed ? pts.length : -1).map((p, i) => {
    const [a, b, c, d] = [at(i - 1), p, at(i + 1), at(i + 2)];
    return `C${xy([b[0] + (c[0] - a[0]) / 6, b[1] + (c[1] - a[1]) / 6])} `
      + `${xy([c[0] - (d[0] - b[0]) / 6, c[1] - (d[1] - b[1]) / 6])} ${xy(c)}`;
  });
  return `M${xy(pts[0])}${segs.join('')}${closed ? 'Z' : ''}`;
};
const disc = (x, y, r, k = 0.5523 * r) => `M${xy([x - r, y])}C${xy([x - r, y - k])} `
  + `${xy([x - k, y - r])} ${xy([x, y - r])}C${xy([x + k, y - r])} ${xy([x + r, y - k])} `
  + `${xy([x + r, y])}C${xy([x + r, y + k])} ${xy([x + k, y + r])} ${xy([x, y + r])}`
  + `C${xy([x - k, y + r])} ${xy([x - r, y + k])} ${xy([x - r, y])}Z`;
const poly = (...pts) => `M${pts.map(xy).join('L')}`;
const box = (x, y, w, h) => `${poly([x, y], [x + w, y], [x + w, y + h], [x, y + h])}Z`;
// The drawings' own colours: the plan's floor, the sea near a coast, the relief bands.
const ART = { tint: '#e3ebe4', shallows: '#0f4a37', coast: '#1f5c45', lowland: '#3f7d5f',
  upland: '#6f9a80', summit: '#dfe7dc' };
const hex = (id) => palette[id] ?? ART[id];
const toSvg = (w, h, shapes) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${shapes.map(({ d, fill, stroke, width, dash }) =>
    `<path d="${d}" fill="${fill ? hex(fill) : 'none'}"${stroke ? ` stroke="${hex(stroke)}" `
    + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"` : ''}${dash
      ? ` stroke-dasharray="${dash.join(' ')}"` : ''}/>`).join('')}</svg>`;

// Elevation: filled bands from the coast to the summit (thin contour lines alias in thumbnails).
const RELIEF = ['coast', 'lowland', 'upland', 'sage', 'summit'];
// An island is a few overlapping lobes. Each band is filled for every lobe, so shapes of one
// colour merge into a single coast with saddles and more than one summit.
function island(lobes, seed, aspect = 0.8) {
  const rand = rng(seed);
  return lobes.map(([cx, cy, r]) => {
    const waves = [2, 3, 4, 6].map((k) => [k, (rand() * 0.26) / Math.sqrt(k), rand() * 6.28]);
    const peak = [cx + (rand() - 0.5) * r * 0.6, cy + (rand() - 0.5) * r * 0.5];
    // The contour at s × r (plus `grow` mm); higher contours drift towards the summit.
    return (s, grow = 0, drift = 0) => Array.from({ length: 64 }, (_, i) => {
      const a = (i / 64) * Math.PI * 2;
      const bump = waves.reduce((sum, [k, amp, ph]) => sum + amp * Math.sin(k * a + ph + drift), 0);
      const [ox, oy] = [peak[0] + (cx - peak[0]) * s, peak[1] + (cy - peak[1]) * s];
      const rr = r * s * (1 + bump) + grow;
      return [ox + Math.cos(a) * rr, oy + Math.sin(a) * rr * aspect];
    });
  });
}
function terrain(w, h, isles, route = []) {
  const lobes = isles.flat();
  const fillAll = (fill, s, grow = 0, drift = 0) =>
    lobes.map((f) => ({ d: spline(f(s, grow, drift), true), fill }));
  const shapes = [{ d: box(0, 0, w, h), fill: 'forest' },
    ...fillAll(RELIEF[0], 1.55, 1, 0.3), ...fillAll('forest', 1.55, 0, 0.3), // a 1 mm sea contour
    ...fillAll('shallows', 1.28),
    ...RELIEF.flatMap((fill, i) => fillAll(fill, 1 - i * 0.19, 0, i * 0.22))]; // coast to summit
  if (route.length) {
    shapes.push({ d: spline(route, false), stroke: 'signal', width: 1.5, dash: [3.2, 2.4] });
    for (const [x, y] of route.slice(1, -1)) {
      shapes.push({ d: disc(x, y, 2.7), fill: 'signal' }, { d: disc(x, y, 1.1), fill: 'paper' });
    }
  }
  return shapes;
}
// The cover: the trim plus the bleed on every side, 176 × 233 mm. The route enters from the
// bleed and leaves through it, past four stops, one per room.
const COVER = TRIM.map((side) => side + 2 * BLEED);
const SCALE = [BLEED + TRIM[0] - OUTER - 20, BLEED + 209]; // the scale bar's corner
const coverShapes = [...terrain(...COVER, [
  island([[112, 152, 32], [140, 170, 24], [128, 128, 18]], 7),
  island([[36, 134, 14], [49, 145, 9]], 11),
  island([[151, 77, 10]], 3)],
[[-4, 202], [40, 137], [98, 160], [136, 150], [151, 78], [182, 36]]),
  // A scale bar ending on the recto's text edge, level with the dates (trim → bleed frame).
  ...[0, 2].map((i) => ({ d: box(SCALE[0] + i * 5, SCALE[1], 5, 1.4), fill: 'paper' })),
  { d: box(...SCALE, 20, 1.4), stroke: 'paper', width: 0.3 }];
const BANDART = [COVER[0], BAND + BLEED]; // the back band, from the bleed's foot
const bandShapes = terrain(...BANDART, [ // the back band: the same sea, another coast
  island([[160, 46, 24], [140, 56, 12], [176, 30, 14]], 5, 0.62),
  island([[4, 26, 13]], 9, 0.7)]); // an islet cut by the trim: its bleed is on the sheet

// The floor plan, 118 × 54 mm: walls with doorways, cases, and the route with numbered stops.
// Stroked numerals in a 0.6 × 1 box: paths like the rest, which the print masters can hold.
const DIGITS = {
  1: 'M0.14 0.22L0.36 0L0.36 1',
  2: 'M0.04 0.24C0.08 -0.06 0.58 -0.06 0.56 0.28C0.54 0.52 0.04 0.7 0.04 1L0.58 1',
  3: 'M0.06 0L0.56 0L0.28 0.38C0.64 0.36 0.66 1 0.28 1C0.16 1 0.06 0.96 0.02 0.88',
  4: 'M0.44 1L0.44 0L0.02 0.66L0.6 0.66',
};
const glyph = (k, x, y, size) => DIGITS[k].replace(/(-?[\d.]+) (-?[\d.]+)/g,
  (_, a, b) => xy([x + (a - 0.3) * size, y + (b - 0.5) * size]));
const PLAN = [118, 54];
const STOPS = [[20, 37], [20, 13], [79, 13], [98, 37]]; // rooms 1 to 4, clockwise
const planShapes = [
  { d: box(1, 1, 116, 48), fill: 'tint' }, // the floor: four rooms round the vestibule
  ...[[6, 42, 14, 4], [23, 42, 12, 4], [5, 4, 4, 14], [50, 17, 22, 4], [86, 4, 24, 4],
    [110, 30, 4, 14], [84, 44, 22, 3]].map((r) => ({ d: box(...r), fill: 'sage' })), // cases
  ...[[[52, 49], [1, 49], [1, 1], [117, 1], [117, 49], [66, 49]], // walls; the gaps are doors
    [[1, 25], [15, 25]], [[25, 25], [40, 25]], [[40, 1], [40, 8]], [[40, 17], [40, 33]],
    [[40, 42], [40, 49]], [[40, 25], [93, 25]], [[103, 25], [117, 25]], [[78, 25], [78, 33]],
    [[78, 42], [78, 49]]].map((pts) => ({ d: poly(...pts), stroke: 'forest', width: 1.2 })),
  // The route starts and ends in the vestibule, just inside the door.
  { d: poly([56, 45], [56, 37], [20, 37], [20, 13], [98, 13], [98, 37], [62, 37], [62, 45]),
    stroke: 'signal', width: 0.9, dash: [2.2, 1.7] },
  { d: `${poly([53, 54], [56, 50.4], [59, 54])}Z`, fill: 'signal' }, // the way in
  ...STOPS.flatMap(([x, y], i) => [{ d: disc(x, y, 3.3), fill: 'signal' },
    { d: glyph(i + 1, x, y, 3.4), stroke: 'paper', width: 0.5 }]),
];
// Each drawing by resource id: its size in mm and its shapes.
const DRAWINGS = { cubierta: [COVER, coverShapes], banda: [BANDART, bandShapes],
  plano: [PLAN, planShapes] };
// #endregion

// #region master: print masters, one-page PDFs written in the inks the designer chose
// C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a
// proof. The palette authors forest, signal, sage and paper with these same builds, so the
// route matches the tab and the kickers match the sea.
const INKS = { forest: [100, 45, 80, 55], shallows: [100, 50, 85, 40], coast: [90, 50, 85, 20],
  lowland: [80, 40, 75, 5], upland: [50, 15, 45, 20], sage: [40, 20, 35, 0],
  summit: [10, 5, 10, 0], tint: [5, 0, 5, 5], signal: [0, 60, 85, 0], paper: [0, 0, 0, 0] };
function pdfPage(w, h, shapes) { // w, h in mm; the content stream flips y to match the SVG
  const s = 72 / 25.4; // pt per mm
  const ink = (id, op) => `${INKS[id].map((v) => v / 100).join(' ')} ${op}`;
  const ops = (d) => d.replace(/([MLCZ])([^MLCZ]*)/g,
    (_, c, v) => `${v.trim()} ${{ M: 'm', L: 'l', C: 'c', Z: 'h' }[c]} `.trimStart());
  const body = shapes.map(({ d, fill, stroke, width, dash }) => [
    'q', fill && ink(fill, 'k'), stroke && ink(stroke, 'K'), stroke && `${width} w 1 J 1 j`,
    dash && `[${dash.join(' ')}] 0 d`, ops(d), fill ? 'f' : 'S', 'Q',
  ].filter(Boolean).join(' ')).join('\n');
  const stream = `${s} 0 0 ${-s} 0 ${h * s} cm\n${body}`;
  const objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(w * s)} ${n(h * s)}] /Contents 4 0 R >>`,
    `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`];
  let pdf = '%PDF-1.4\n';
  const offsets = objects.map((object, i) => {
    const offset = pdf.length;
    pdf += `${i + 1} 0 obj\n${object}\nendobj\n`;
    return offset;
  });
  const xref = pdf.length;
  pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
    + offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
    + `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return new TextEncoder().encode(pdf); // ASCII only, so string length = byte length
}
const masters = new Map(Object.entries(DRAWINGS) // by fileId: cubierta.pdf, banda.pdf, plano.pdf
  .map(([id, [size, shapes]]) => [`${id}.pdf`, pdfPage(...size, shapes)]));
// #endregion

// Every drawing is an SVG for the screen and names its print master for the press.
const svgResource = (id, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`,
    width: DRAWINGS[id][0][0] * PX, height: DRAWINGS[id][0][1] * PX } });
const resources = [
  svgResource('cubierta', { altText: 'Islas inventadas en verdes escalonados sobre un mar '
    + 'verde oscuro, cruzadas por una ruta naranja con cuatro paradas.' }),
  svgResource('banda', { altText: 'La costa de una isla inventada.' }),
  svgResource('plano', { caption: 'Plano de la exposición, con el itinerario en naranja.',
    altText: 'Plano: cuatro salas alrededor del vestíbulo y un itinerario '
    + 'naranja que las recorre en el sentido de las agujas del reloj.' }),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages use. Layout measures with the browser's fonts, so the kit loads them
// from Fontsource before the first build; the PDF embeds the same files,
// Fontsource's latin subsets, which cover Spanish (gotcha: latin-subset).
const FONTS = { Karla: ['400', '400i', '700'], 'Space Grotesk': ['400', '700'],
  'Space Mono': ['400', '400i', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all(Object.entries(DRAWINGS)
  .map(([id, [size, shapes]]) => loadSvg(`${id}.svg`, toSvg(...size, shapes))));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Cartografías imaginarias · PDF listo para imprenta' });
offerPdf(() => pressPdf(doc, 'press', masters), `${RECIPE}.pdf`); // the file for the press
offerPdf(() => pressPdf(doc, 'proof', masters), `${RECIPE}-proof.pdf`); // a proof to read
// The kit names both buttons alike; once built, each download link carries its file name.
const button = (file) => document.querySelector(`[data-postext-pdf="${file}"]`);
button(`${RECIPE}.pdf`).textContent = 'Press PDF (PDF/X-4)';
button(`${RECIPE}-proof.pdf`).textContent = 'Greyscale proof';

// The preflight: what a printer would reject, checked with the same profile.
const transform = outputTransform(await loadOutputProfile(PRINT.outputProfile, PROFILES));
const findings = preflightDocument(doc, { resources, transform });
const report = document.createElement('p');
report.textContent = findings.length === 0
  ? `Preflight (${PRINT.standard}, ${PRINT.outputProfile}): no findings`
  : `Preflight: ${findings.map((f) => `${f.kind} on page ${f.pageIndex + 1}`).join('; ')}`;
button(`${RECIPE}.pdf`).after(report);

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

### 5 mm Anschnitt verlangen

Bilder, Reiter und Maßstabsleiste sind aus `BLEED` bemessen, und die Schnittmarken beginnen an der Kante des Anschnitts, sodass eine Druckerei, die 5 mm verlangt, nur diese Änderung braucht, und jeder Bogen wächst auf 196 × 253 mm.

```diff
-const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
+const BLEED = 5; // mm of artwork past the trim: the cover, the tab and the back band reach it
```

### Die Bilder aus ihren SVGs drucken

Ohne `pdfFileId` zeichnet die Druckdatei jedes Bild aus seinem SVG, weiterhin als Vektoren und über das Profil: Das in CMYK angelegte Waldgrün behält C100 M45 Y80 K55, während die anderen Grüntöne der Inseln die Separationen des Profils statt der handverlesenen erhalten.

```diff
-  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`,
+  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`,
```

## Häufige Fehler

- **Kein <marker> und keine Filter in SVG-Grafiken (Raster-Ersatz).** Eine SVG-Abbildung bleibt im PDF nur ohne <marker>, Filter und Masken ein Vektor; andernfalls fällt sie auf ein Raster zurück, und tief verschachtelte Filter können sie in Chrome leer erscheinen lassen. Zeichnen Sie Pfeilspitzen als Pfade.
- **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.

- Ein PDF/X-Standard schreibt immer CMYK, egal was `colorSpace` sagt, deshalb überschreibt der Graustufenproof `print` mit `{ standard: 'none' }` neben `colorSpace: 'grayscale'`.
- Ein PDF/X-Rendering schlägt ohne sein Ausgabeprofil fehl. `renderToPdf` holt `fogra51.icc` von `profileBaseUrl` (hier `/icc/` von postext.dev); übergeben Sie die Bytes der Datei als `outputProfile`, um sie selbst bereitzustellen.
- Der Preflight markiert Text unter 9 pt, der in mehr als einer Farbe gesetzt ist. Die fetten Beschriftungen zu 7,5 pt in Waldgrün halten auf gestrichenem Papier in vier Farben, deshalb ist `print.preflight.smallTextSize` 7 pt; das Impressum zu 6,3 pt, das in Salbeigrün drei Farben auf einem vierfarbigen Band wäre, ist stattdessen papierfarben ausgespart.
- Eine PDF/X-Datei trägt keine Link-Annotationen, da eine Datei für die Druckmaschine sie nicht in ihrer Bleed-Box haben darf; die Lesezeichen bleiben.
- Nach dem Bauen wird jede Schaltfläche zu einem Link „Open the PDF ↗“ und einem Download-Link, und nur die Download-Links tragen die Dateinamen.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: Karla (OFL-1.1), Space Grotesk (OFL-1.1), Space Mono (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 051 · Riso-Zine in zwei Farben: Grafik in einer Sonderfarbe](https://postext.dev/de/cookbook/riso-zine-single-ink.md): Ein vierseitiges Zine für einen Risographen mit zwei Trommeln: farbige Zeichnungen drucken als Pinktöne, umgefärbt von der Engine im Canvas und im PDF. · Stufe 3 (Fortgeschritten) · Magazine und Zines
- [Nº 039 · Barrierefreies getaggtes PDF (PDF/UA)](https://postext.dev/de/cookbook/accessible-tagged-pdf.md): Ein Abfallratgeber als getaggtes PDF/UA-1 mit Alternativtext, Kopfzellen, Dokumentsprache und verlinktem Inhalt; die Vorschau zeichnet die Tags auf Seite 3. · Stufe 2 (Mittel) · Berichte
- [Nº 003 · Kapitelauftakt auf einem randabfallenden Farbband](https://postext.dev/de/cookbook/chapter-opener-bleed-band.md): Ein advancedDesign-Auftakt an der H1: ein randabfallendes Band, die Kapitelnummer auf seinem Fuß, Dachzeile und Vorspann aus den Attributen der Überschrift. · Stufe 3 (Fortgeschritten) · Lehrbücher
