# Ein Filmclubprogramm mit QR-Codes auf den Vorschaubildern

> Jeder Film ist eine Videoressource, deren Vorschaubild mit Abspielzeichen und einem QR-Code für YouTube gedruckt wird; im PDF ist das Bild auch ein Link.

- HTML-Fassung: https://postext.dev/de/cookbook/film-club-video-qr
- Rezept Nº 131 · Abbildungen und Bilder · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Einzelblätter und Akzidenzen, Magazine und Zines
- Benötigt postext ≥ 1.28.0, postext-pdf ≥ 1.28.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/film-club-video-qr/en/p01.webp?v=4c3f0ee7), [2](https://postext.dev/cookbook/film-club-video-qr/en/p02.webp?v=4c3f0ee7), [3](https://postext.dev/cookbook/film-club-video-qr/en/p03.webp?v=4c3f0ee7), [4](https://postext.dev/cookbook/film-club-video-qr/en/p04.webp?v=4c3f0ee7), [5](https://postext.dev/cookbook/film-club-video-qr/en/p05.webp?v=4c3f0ee7), [6](https://postext.dev/cookbook/film-club-video-qr/en/p06.webp?v=4c3f0ee7)
- PDF: https://postext.dev/cookbook/film-club-video-qr/en/film-club-video-qr.pdf?v=4c3f0ee7
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=film-club-video-qr&lang=en (.postext: https://postext.dev/cookbook/film-club-video-qr/en/film-club-video-qr.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/film-club-video-qr.md), [es](https://postext.dev/es/cookbook/film-club-video-qr.md), [ca](https://postext.dev/ca/cookbook/film-club-video-qr.md), [fr](https://postext.dev/fr/cookbook/film-club-video-qr.md), [it](https://postext.dev/it/cookbook/film-club-video-qr.md), [pt](https://postext.dev/pt/cookbook/film-club-video-qr.md), [zh](https://postext.dev/zh/cookbook/film-club-video-qr.md), [ja](https://postext.dev/ja/cookbook/film-club-video-qr.md), [ar](https://postext.dev/ar/cookbook/film-club-video-qr.md)

## Kurz gesagt

Ein sechsseitiges Programm für die Herbstsaison eines Filmclubs mit vier Kurzfilmen. Jede Filmseite zeigt ein Bild mit einem Code, den ein Handy scannen kann, um den Film anzusehen, und im PDF lässt sich das Bild anklicken.

## Was Sie setzen

Das Herbstprogramm des Lumen Film Club, ein Heft von 165 × 235 mm auf cremefarbenem Papier: ein bildschirmschwarzes Umschlagband mit dem Titel der Saison in Big Shoulders Display, dann eine Seite für jeden von vier offenen Filmen der Blender Foundation. Jede Filmseite beginnt mit einer roten Dachzeile für die Vorstellung, dem Titel in schmalen Versalien und einer Zeile mit den Mitwirkenden, dann folgt eine Titelkarte über die volle Satzbreite von 125 mm. Ein rotes Abspielzeichen sitzt in der Ecke oben links der Karte, ein QR-Code von 20 mm unten rechts. Wer einen Freitag verpasst hat, richtet ein Handy auf den Code, und der Film öffnet sich auf YouTube; im PDF ist die ganze Karte der Link. Die Kurzadresse unter jeder Karte ist für ein Handy da, das den Code nicht liest.

**Dieses Rezept beantwortet:**

- Wie drucke ich ein Video in ein Programm, sodass man es von der Seite scannen oder im PDF anklicken kann?

## Die kurze Antwort

```js
// script.js, Zeilen 35–60
const videoStyle = {
  // The mark sits top left and the code bottom right, so they never meet on a 70 mm poster.
  playMark: { shape: 'rounded', position: 'top-left', size: mm(8), inset: mm(4),
    color: col('paper'), background: col('accent'), backgroundOpacity: 1 },
  // https://youtu.be/<id> is 28 characters: 29 modules at level Q, and a quiet zone of 2 a side
  // makes modules of 0.61 mm on a 20 mm plate, twice what a phone camera needs.
  qr: { position: 'bottom-right', size: mm(20), inset: mm(3), errorCorrection: 'Q',
    quietZone: 2, color: col('ink'), background: col('paper'), radius: mm(1.2) },
  linkPoster: true, // the PDF draws a link over each poster
};
const film = (f) => ({
  id: f.id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0,
  caption: `${f.title}. ${t(f.about)}.`, altText: t(f.alt),
  note: `${f.licence} · ${f.address}`, // for a phone that does not read the code
  placement: { position: 'here' }, // set where ::resource puts it, under the opener
  video: {
    // The short address printed under the picture, expanded to the one the QR code holds.
    source: 'youtube', url: videoWatchUrl(parseVideoUrl(f.address)), // https://youtu.be/<id>
    // Any picture can be the poster: here a title card drawn below, at its pixel size.
    poster: { fileId: `${f.id}.png`, format: 'png', width: CARD.w, height: CARD.h },
  },
});
// "Film 1" instead of "Video 1.1": one count for the season, not per chapter.
const [FILM, FILMS_NAME] = [t({ en: 'Film', es: 'Película' }), t({ en: 'Films', es: 'Películas' })];
const filmType = (type) => ({ ...type, name: FILM, namePlural: FILMS_NAME, shortLabel: FILM,
  captionPrefix: FILM, numberingTemplate: '{n}', resetOn: 'never' });
```

## Zutaten

**Zeigt**

- [Videos als Ressourcen](https://postext.dev/de/docs/document-format.md#videos): YouTube-, Vimeo- oder selbst gehostete Videos, platziert, beschriftet und nummeriert wie Abbildungen (Video 1.1); der Druck zeigt das Vorschaubild, der HTML-Betrachter und EPUB spielen sie ab.
- [Video-Vorschaubilder: Abspielzeichen und QR-Code](https://postext.dev/de/docs/configuration-resources.md#videostil): videoStyle: das Abspielzeichen und der QR-Code auf dem Vorschaubild eines Videos, der Link des Vorschaubilds und die Player-Optionen der HTML- und EPUB-Ausgabe.

**Verwendet außerdem**

- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Nummerierte Bildunterschriften](https://postext.dev/de/docs/document-format.md#nummerierung-beim-ersten-verweis)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Format der Bildunterschrift](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Quellen- und Bildnachweise](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [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)
- [Kolumnentitel je Abschnitt](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), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`videoStyle`](https://postext.dev/de/docs/configuration-resources.md#videostil)

**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), [`parseVideoUrl`](https://postext.dev/de/docs/document-format.md#videos), [`prepareFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`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), [`videoWatchUrl`](https://postext.dev/de/docs/document-format.md#videos)

**Schriften**

- Literata (OFL-1.1), Big Shoulders Display (OFL-1.1), Barlow Semi Condensed (OFL-1.1)

## Zubereitung

### 1 · Jeden Film zu einer Videoressource machen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/film-club-video-qr.md#die-kurze-antwort). Eine Videoressource wird wie eine Abbildung platziert, beschriftet und nummeriert, und der Druck zeigt sie als ihr Vorschaubild mit zwei Überlagerungen. Abspielzeichen und Code gehen in gegenüberliegende Ecken, sodass sie sich nie berühren, und das Vorschaubild lässt mit 70 mm Höhe Platz für beide. Der Code misst 20 mm mit einer Ruhezone von zwei Modulen: `https://youtu.be/` plus eine elfstellige ID ergibt 29 Module auf Stufe Q, jedes Modul misst also 0,61 mm, etwa das Doppelte dessen, was eine Handykamera braucht. Q ist die höchste Stufe, die bei dieser Länge 29 Module behält (die Engine hebt M von selbst darauf an); H bräuchte 33 und verkleinerte die Module auf 0,54 mm. Der eingebaute Typ `video` würde *Video 1.1* drucken; in *Film* umbenannt und mit `{n}` über das ganze Dokument gezählt, nummeriert er die vier Vorstellungen der Saison als *Film 1* bis *Film 4*, und die Zitate auf dem Umschlag drucken dieselben Wörter.

### 2 · Die Adresse kurz halten und die Engine sie erweitern lassen

```js
// script.js, Zeilen 64–85
const FILMS = [
  { id: 'elephants-dream', address: 'youtu.be/TLkA0RELQ1g', title: 'Elephants Dream', year: 2006,
    director: 'Bassam Kurdali', licence: 'CC BY 2.5', colour: 'orange', motif: 'gears',
    about: { en: 'Project Orange, Blender Foundation', es: 'Proyecto Orange, Blender Foundation' },
    alt: { en: 'Title card for Elephants Dream: copper gears and cables on deep teal.',
      es: 'Cartela de Elephants Dream: engranajes y cables de cobre sobre verde azulado.' } },
  { id: 'big-buck-bunny', address: 'youtu.be/aqz-KE-bpKQ', title: 'Big Buck Bunny', year: 2008,
    director: 'Sacha Goedegebure', licence: 'CC BY 3.0', colour: 'peach', motif: 'meadow',
    about: { en: 'Project Peach, Blender Institute', es: 'Proyecto Peach, Blender Institute' },
    alt: { en: 'Title card for Big Buck Bunny: a peach sun over green hills and butterflies.',
      es: 'Cartela de Big Buck Bunny: un sol melocotón sobre colinas verdes y mariposas.' } },
  { id: 'tears-of-steel', address: 'youtu.be/R6MlUcmOul8', title: 'Tears of Steel', year: 2012,
    director: 'Ian Hubert', licence: 'CC BY 3.0', colour: 'mango', motif: 'city',
    about: { en: 'Project Mango, Blender Institute', es: 'Proyecto Mango, Blender Institute' },
    alt: { en: 'Title card for Tears of Steel: a skyline with a church spire under a ringed moon.',
      es: 'Cartela de Tears of Steel: un perfil urbano con una aguja de iglesia bajo una luna.' } },
  { id: 'spring', address: 'youtu.be/WhWc3b3KhnY', title: 'Spring', year: 2019,
    director: 'Andy Goralczyk', licence: 'CC BY 4.0', colour: 'spring', motif: 'ridges',
    about: { en: 'Blender Studio', es: 'Blender Studio' },
    alt: { en: 'Title card for Spring: misty ridges, dark firs and one red light.',
      es: 'Cartela de Spring: crestas en la niebla, abetos oscuros y una luz roja.' } },
];
```

Die Adresse in jedem Eintrag ist die unter der Karte gedruckte, `youtu.be/TLkA0RELQ1g`, die ein Leser eintippen kann. `parseVideoUrl` liest jede Form von YouTube oder Vimeo, und `videoWatchUrl` schreibt aus dem Gelesenen die kanonische Adresse: QR-Code und PDF-Link enthalten beide `https://youtu.be/<id>`. Ein Tippfehler, der keine gültige ID übrig lässt, lässt `parseVideoUrl` `undefined` zurückgeben, und der Pen hält vor dem Build an; ein kaputter Link erreicht also nie die Druckerei.

### 3 · Eine Titelkarte als Vorschaubild zeichnen

```js
// script.js, Zeilen 247–393
// The 2D canvas paints the motifs and sets the type in the faces the page has loaded.
// Seeded, so every run draws the same cards.
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
  * (1 - k) + parseInt(b.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`;
const MOTIFS = {
  gears(g, rand, bg) { // a machine without end: three gears and the cables between them
    const copper = '#d98a45';
    g.lineWidth = 4;
    for (let i = 0; i < 7; i++) {
      g.strokeStyle = `${copper}88`;
      g.beginPath();
      g.moveTo(0, 90 + rand() * 380);
      g.bezierCurveTo(300 + rand() * 300, rand() * 500, 600 + rand() * 200, 100 + rand() * 400,
        1000 + rand() * 300, 120 + rand() * 300);
      g.stroke();
    }
    for (const [x, y, r, teeth] of [[1150, 300, 215, 18], [835, 160, 112, 11], [905, 455, 88, 9]]) {
      g.beginPath();
      for (let k = 0; k <= teeth * 4; k++) {
        const a = (k / (teeth * 4)) * Math.PI * 2;
        const rr = k % 4 < 2 ? r : r * 0.84;
        g.lineTo(x + rr * Math.cos(a), y + rr * Math.sin(a));
      }
      g.fillStyle = mix(bg, '#000000', 0.25);
      g.fill();
      g.lineWidth = 6;
      g.strokeStyle = copper;
      g.stroke();
      g.beginPath();
      g.arc(x, y, r * 0.3, 0, Math.PI * 2);
      g.stroke();
    }
  },
  meadow(g, rand, bg) { // a peach sun, three hills, butterflies
    g.fillStyle = '#f5b98a';
    g.beginPath();
    g.arc(1180, 250, 165, 0, Math.PI * 2);
    g.fill();
    for (let i = 0; i < 6; i++) { // butterflies: two pairs of wings each
      const [x, y, s] = [760 + rand() * 640, 90 + rand() * 300, 14 + rand() * 12];
      g.fillStyle = i % 2 ? '#f6f1e7' : '#e9b44c';
      for (const side of [-1, 1]) {
        g.beginPath();
        g.moveTo(x, y);
        g.lineTo(x + side * s * 1.4, y - s);
        g.lineTo(x + side * s * 1.1, y + s * 0.7);
        g.fill();
      }
    }
    [[470, 0.15], [560, 0.35], [650, 0.55]].forEach(([base, dark], layer) => {
      g.fillStyle = mix(bg, '#000000', dark);
      g.beginPath();
      g.moveTo(0, CARD.h);
      for (let x = 0; x <= CARD.w; x += 20) {
        g.lineTo(x, base + 60 * Math.sin(x / (260 + layer * 90) + layer * 2));
      }
      g.lineTo(CARD.w, CARD.h);
      g.fill();
    });
  },
  city(g, rand, bg) { // a skyline, one church spire, a ringed moon
    g.strokeStyle = '#e9b44c';
    g.lineWidth = 5;
    g.beginPath();
    g.arc(1240, 210, 105, 0, Math.PI * 2);
    g.stroke();
    g.beginPath();
    g.ellipse(1240, 210, 190, 34, -0.3, 0, Math.PI * 2);
    g.stroke();
    const shade = mix(bg, '#000000', 0.45);
    for (let x = 520; x < CARD.w; x += 46 + rand() * 60) {
      const [w, h] = [40 + rand() * 70, 90 + rand() * 300];
      g.fillStyle = shade;
      g.fillRect(x, 560 - h, w, CARD.h);
      g.fillStyle = '#e9b44c';
      for (let wy = 580 - h; wy < 540; wy += 26) {
        if (rand() < 0.18) g.fillRect(x + 8 + rand() * (w - 22), wy, 8, 12);
      }
    }
    g.fillStyle = shade; // the Oude Kerk's spire stands over the rest
    g.beginPath();
    g.moveTo(940, 560);
    g.lineTo(1000, 90);
    g.lineTo(1060, 560);
    g.fill();
    g.fillRect(0, 560, CARD.w, CARD.h);
  },
  ridges(g, rand, bg) { // misty ridges, dark firs and one red light
    g.fillStyle = mix(bg, '#f6f1e7', 0.55);
    g.beginPath();
    g.arc(1260, 170, 85, 0, Math.PI * 2);
    g.fill();
    [[300, 0.35], [400, 0.15], [500, -0.15], [600, -0.4]].forEach(([base, k]) => {
      g.fillStyle = k > 0 ? mix(bg, '#f6f1e7', k) : mix(bg, '#000000', -k);
      g.beginPath();
      g.moveTo(0, CARD.h);
      for (let x = 0; x <= CARD.w; x += 80) g.lineTo(x, base - rand() * 110);
      g.lineTo(CARD.w, CARD.h);
      g.fill();
    });
    g.fillStyle = mix(bg, '#000000', 0.6);
    for (let i = 0; i < 9; i++) {
      const [x, h] = [920 + i * 58 + rand() * 30, 130 + rand() * 120];
      g.beginPath();
      g.moveTo(x - h * 0.22, 640);
      g.lineTo(x, 640 - h);
      g.lineTo(x + h * 0.22, 640);
      g.fill();
    }
    g.fillStyle = '#e0452c';
    g.beginPath();
    g.arc(640, 500, 11, 0, Math.PI * 2);
    g.fill();
  },
};
async function titleCard(f) {
  const canvas = Object.assign(document.createElement('canvas'), { width: CARD.w, height: CARD.h });
  const g = canvas.getContext('2d');
  const bg = palette[f.colour];
  g.fillStyle = bg;
  g.fillRect(0, 0, CARD.w, CARD.h);
  MOTIFS[f.motif](g, mulberry32(f.year), bg);
  // Lower left, clear of the play mark (top left) and the QR plate (bottom right).
  g.fillStyle = '#e9b44c';
  g.font = `600 34px "${LABEL}"`;
  g.letterSpacing = '7px';
  g.fillText(`${t({ en: 'Blender open movie', es: 'Película abierta de Blender' })} · ${f.year}`
    .toUpperCase(), 96, 612);
  g.letterSpacing = '0px';
  g.fillStyle = '#f6f1e7';
  let size = 154;
  do g.font = `800 ${size -= 4}px "${DISPLAY}"`;
  while (g.measureText(f.title.toUpperCase()).width > 1080);
  g.fillText(f.title.toUpperCase(), 92, 760);
  g.font = `500 40px "${LABEL}"`;
  g.fillText(`${t({ en: 'Directed by', es: 'Dirigida por' })} ${f.director}`, 96, 826);
  const blob = await new Promise((done) => canvas.toBlob(done, 'image/png'));
  await loadImage(`${f.id}.png`, URL.createObjectURL(blob)); // the canvas and the PDF
}
```

In den meisten Projekten ist das Vorschaubild ein Bild aus dem Film: Die Sandbox holt es, wenn Sie die Adresse eingeben, und im Code listet `youtubePosterUrls(id)` die Vorschaubilder von YouTube vom größten (1280 × 720) abwärts. Die Bilder der Plattformen gehören zu den Filmen und deren Lizenzen, und ein gedrucktes Programm kann eigene Titelkarten verwenden; jeder Film erhält hier daher eine auf einem Canvas in seiner Farbe gezeichnete, mit dem Motiv, Titel, Jahr und Regisseur des Films. Die Karte hält die Ecken oben links und unten rechts für die Überlagerungen frei. Ein Canvas zeichnet jede Karte und setzt ihre Schrift in den Schriften des Hefts, die die Seite bis dahin geladen hat. Die Karte misst 1600 × 900 Pixel, das 16:9 der Filme, und `video.poster` gibt diese Größe an: Bei der Satzbreite von 125 mm wird sie mit 325 dpi gedruckt. Ein Video ohne Vorschaubild erscheint als dunkler Kasten in 16:9 mit demselben Zeichen und Code, und der Build warnt davor.

### 4 · Das Vorschaubild unter den Auftakt setzen

```js
// script.js, Zeilen 89–135
const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text',
  id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...more }); // a foot wraps too, where the default is '…'
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s * 0.2) });
const BAND = 126; // mm: the cover's screen-black band, from the trim's top edge
const MEASURE = TRIM.w - INNER - OUTER;
const CHIP = (MEASURE - 9) / 4; // four chips in the films' colours, 3 mm apart
const coverStyle = () => ({
  id: 'cover', numbered: false, footer: { elements: [] }, // no folio on the cover
  marginBottom: pt(LEAD), // a line of paper between the band and the text
  advancedDesign: { enabled: true, minHeight: mm(BAND - TOP), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
      placement: { ...at('page', 'top-left', 0, 0), size: { width: 'fill', height: mm(BAND) } } },
    text('kicker', '{attr.kicker}', LABEL, 9, 'gold', at('page', 'top-left', INNER, 14), caps(9)),
    text('title', '{titleText}', DISPLAY, 88, 'paper', at('page', 'top-left', INNER - 1, 28,
      MEASURE), { fontWeight: 800, textTransform: 'uppercase', lineHeight: 0.86 }),
    text('standfirst', '{attr.standfirst}', TEXT, 13, 'paper',
      at('page', 'top-left', INNER, 85, 104), { italic: true, lineHeight: 1.3 }),
    ...FILMS.flatMap((f, i) => [
      { kind: 'box', id: `chip-${i}`, style: { backgroundColor: col(f.colour) },
        placement: { ...at('page', 'top-left', INNER + i * (CHIP + 3), 103),
          size: { width: mm(CHIP), height: mm(9) } } },
      text(`chip-label-${i}`, t({ en: `${6 + 7 * i} Nov`, es: `${6 + 7 * i} nov` }), LABEL, 8,
        'paper', at('page', 'top-left', INNER + i * (CHIP + 3) + 2.5, 105.6), caps(8)),
    ]),
    text('dates', '{attr.dates}', LABEL, 8.5, 'gold', at('page', 'top-left', INNER, 116), caps(9)),
  ] } },
});
const opener = () => ({ enabled: true, minHeight: mm(22), slot: { elements: [
  text('kicker', '{attr.kicker}', LABEL, 8.5, 'accent', at('container', 'top-left', 0, 0),
    caps(8.5)),
  text('title', '{titleText}', DISPLAY, 34, 'ink', at('#kicker', 'below', 0, 1.5, MEASURE),
    { fontWeight: 800, textTransform: 'uppercase', lineHeight: 1 }),
  text('credits', '{attr.credits}', LABEL, 9.5, 'muted', at('#title', 'below', 0, 1.5, MEASURE),
    { fontWeight: 500 }),
] } });
const foot = (id, content, parity, edge, x, more = {}) => text(id, content, LABEL, 7.5, 'muted',
  at('page', edge, x, TRIM.h - 13), { ...caps(7.5), parity, pages: 'all', ...more });
const footer = { elements: [ // folios at the outer foot, the programme's name beside them
  foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }),
  foot('verso-title', '{title}', 'even', 'top-left', OUTER + 7),
  foot('recto-title', '{title}', 'odd', 'top-right', -(OUTER + 7), { align: 'right' }),
  foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER,
    { color: col('ink'), align: 'right' }),
] };
```

Jeder Film ist eine Überschrift der ersten Ebene, deren Auftakt die Dachzeile der Vorstellung, den Titel und die Zeile mit den Mitwirkenden stapelt, alle drei aus Überschriftenattributen. `::resource` setzt das Vorschaubild mit `position: 'here'` direkt darunter, sodass jede Filmseite dieselbe Reihenfolge hat. Der Umschlag ist ein Überschriftenformat, dessen Band, Titel und Chips Gestaltungselemente sind; die vier Chips nehmen die Farben der Filme aus der Palette, wie die Titelkarten.

### 5 · Eine Palette für Seiten und Karten

```js
// script.js, Zeilen 16–27
const palette = {
  ink: '#1d1b20', paper: '#f6f1e7', // text and the cover band; the page
  accent: '#b8321c', gold: '#e9b44c', muted: '#6b6157', // kickers; the band's accent; credits
  orange: '#1f3d44', peach: '#3f6b35', mango: '#2c3746', spring: '#3d5a78', // the title cards
};
// A linked colour carries its hex too, so designs and defaults read the same value.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
```

Textfarbe, Rot und Creme gehören dem Heft; die vier Filmfarben (benannt nach den Blender-Projekten Orange, Peach und Mango, dazu Spring) färben sowohl die Chips auf dem Umschlag als auch die Karten. Das Abspielzeichen hat dasselbe Rot wie die Dachzeilen, und die QR-Platte ist das Creme der Seite mit Modulen in der Textfarbe: ein dunkler Code auf heller Platte, der Kontrast, den jede Lese-App erwartet.

## 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/film-club-video-qr

### script.js

```js
// ═══ Postext Cookbook · Nº 131 · Film club programme with video QR codes ═════════
// https://postext.dev/en/cookbook/film-club-video-qr
// Code: MIT · Text and title cards: original (CC BY 4.0) · Films: Blender Foundation (CC BY)
// Fonts: Literata, Big Shoulders Display, Barlow Semi Condensed (SIL OFL) · Needs postext ≥ 1.28.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
  defaultResourceTypes, parseVideoUrl, videoWatchUrl,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'film-club-video-qr';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a screen-black ink, one red, and a colour for each film
const palette = {
  ink: '#1d1b20', paper: '#f6f1e7', // text and the cover band; the page
  accent: '#b8321c', gold: '#e9b44c', muted: '#6b6157', // kickers; the band's accent; credits
  orange: '#1f3d44', peach: '#3f6b35', mango: '#2c3746', spring: '#3d5a78', // the title cards
};
// A linked colour carries its hex too, so designs and defaults read the same value.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Literata', 'Big Shoulders Display', 'Barlow Semi Condensed'];
const TRIM = { w: 165, h: 235 }; // mm: a programme booklet, a little larger than A5
const [TOP, INNER, OUTER, LEAD] = [22, 18, 22, 14]; // margins in mm (a 125 mm measure); leading, pt
const CARD = { w: 1600, h: 900 }; // px: the title cards, 16:9 like the films, 325 dpi at 125 mm

// #region answer: a video resource per film, printed as its poster with a play mark and a QR code
const videoStyle = {
  // The mark sits top left and the code bottom right, so they never meet on a 70 mm poster.
  playMark: { shape: 'rounded', position: 'top-left', size: mm(8), inset: mm(4),
    color: col('paper'), background: col('accent'), backgroundOpacity: 1 },
  // https://youtu.be/<id> is 28 characters: 29 modules at level Q, and a quiet zone of 2 a side
  // makes modules of 0.61 mm on a 20 mm plate, twice what a phone camera needs.
  qr: { position: 'bottom-right', size: mm(20), inset: mm(3), errorCorrection: 'Q',
    quietZone: 2, color: col('ink'), background: col('paper'), radius: mm(1.2) },
  linkPoster: true, // the PDF draws a link over each poster
};
const film = (f) => ({
  id: f.id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0,
  caption: `${f.title}. ${t(f.about)}.`, altText: t(f.alt),
  note: `${f.licence} · ${f.address}`, // for a phone that does not read the code
  placement: { position: 'here' }, // set where ::resource puts it, under the opener
  video: {
    // The short address printed under the picture, expanded to the one the QR code holds.
    source: 'youtube', url: videoWatchUrl(parseVideoUrl(f.address)), // https://youtu.be/<id>
    // Any picture can be the poster: here a title card drawn below, at its pixel size.
    poster: { fileId: `${f.id}.png`, format: 'png', width: CARD.w, height: CARD.h },
  },
});
// "Film 1" instead of "Video 1.1": one count for the season, not per chapter.
const [FILM, FILMS_NAME] = [t({ en: 'Film', es: 'Película' }), t({ en: 'Films', es: 'Películas' })];
const filmType = (type) => ({ ...type, name: FILM, namePlural: FILMS_NAME, shortLabel: FILM,
  captionPrefix: FILM, numberingTemplate: '{n}', resetOn: 'never' });
// #endregion

// #region films: the four films, their YouTube addresses and what their title cards draw
const FILMS = [
  { id: 'elephants-dream', address: 'youtu.be/TLkA0RELQ1g', title: 'Elephants Dream', year: 2006,
    director: 'Bassam Kurdali', licence: 'CC BY 2.5', colour: 'orange', motif: 'gears',
    about: { en: 'Project Orange, Blender Foundation', es: 'Proyecto Orange, Blender Foundation' },
    alt: { en: 'Title card for Elephants Dream: copper gears and cables on deep teal.',
      es: 'Cartela de Elephants Dream: engranajes y cables de cobre sobre verde azulado.' } },
  { id: 'big-buck-bunny', address: 'youtu.be/aqz-KE-bpKQ', title: 'Big Buck Bunny', year: 2008,
    director: 'Sacha Goedegebure', licence: 'CC BY 3.0', colour: 'peach', motif: 'meadow',
    about: { en: 'Project Peach, Blender Institute', es: 'Proyecto Peach, Blender Institute' },
    alt: { en: 'Title card for Big Buck Bunny: a peach sun over green hills and butterflies.',
      es: 'Cartela de Big Buck Bunny: un sol melocotón sobre colinas verdes y mariposas.' } },
  { id: 'tears-of-steel', address: 'youtu.be/R6MlUcmOul8', title: 'Tears of Steel', year: 2012,
    director: 'Ian Hubert', licence: 'CC BY 3.0', colour: 'mango', motif: 'city',
    about: { en: 'Project Mango, Blender Institute', es: 'Proyecto Mango, Blender Institute' },
    alt: { en: 'Title card for Tears of Steel: a skyline with a church spire under a ringed moon.',
      es: 'Cartela de Tears of Steel: un perfil urbano con una aguja de iglesia bajo una luna.' } },
  { id: 'spring', address: 'youtu.be/WhWc3b3KhnY', title: 'Spring', year: 2019,
    director: 'Andy Goralczyk', licence: 'CC BY 4.0', colour: 'spring', motif: 'ridges',
    about: { en: 'Blender Studio', es: 'Blender Studio' },
    alt: { en: 'Title card for Spring: misty ridges, dark firs and one red light.',
      es: 'Cartela de Spring: crestas en la niebla, abetos oscuros y una luz roja.' } },
];
// #endregion

// #region furniture: the cover band, the film openers and the folios
const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text',
  id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...more }); // a foot wraps too, where the default is '…'
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s * 0.2) });
const BAND = 126; // mm: the cover's screen-black band, from the trim's top edge
const MEASURE = TRIM.w - INNER - OUTER;
const CHIP = (MEASURE - 9) / 4; // four chips in the films' colours, 3 mm apart
const coverStyle = () => ({
  id: 'cover', numbered: false, footer: { elements: [] }, // no folio on the cover
  marginBottom: pt(LEAD), // a line of paper between the band and the text
  advancedDesign: { enabled: true, minHeight: mm(BAND - TOP), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
      placement: { ...at('page', 'top-left', 0, 0), size: { width: 'fill', height: mm(BAND) } } },
    text('kicker', '{attr.kicker}', LABEL, 9, 'gold', at('page', 'top-left', INNER, 14), caps(9)),
    text('title', '{titleText}', DISPLAY, 88, 'paper', at('page', 'top-left', INNER - 1, 28,
      MEASURE), { fontWeight: 800, textTransform: 'uppercase', lineHeight: 0.86 }),
    text('standfirst', '{attr.standfirst}', TEXT, 13, 'paper',
      at('page', 'top-left', INNER, 85, 104), { italic: true, lineHeight: 1.3 }),
    ...FILMS.flatMap((f, i) => [
      { kind: 'box', id: `chip-${i}`, style: { backgroundColor: col(f.colour) },
        placement: { ...at('page', 'top-left', INNER + i * (CHIP + 3), 103),
          size: { width: mm(CHIP), height: mm(9) } } },
      text(`chip-label-${i}`, t({ en: `${6 + 7 * i} Nov`, es: `${6 + 7 * i} nov` }), LABEL, 8,
        'paper', at('page', 'top-left', INNER + i * (CHIP + 3) + 2.5, 105.6), caps(8)),
    ]),
    text('dates', '{attr.dates}', LABEL, 8.5, 'gold', at('page', 'top-left', INNER, 116), caps(9)),
  ] } },
});
const opener = () => ({ enabled: true, minHeight: mm(22), slot: { elements: [
  text('kicker', '{attr.kicker}', LABEL, 8.5, 'accent', at('container', 'top-left', 0, 0),
    caps(8.5)),
  text('title', '{titleText}', DISPLAY, 34, 'ink', at('#kicker', 'below', 0, 1.5, MEASURE),
    { fontWeight: 800, textTransform: 'uppercase', lineHeight: 1 }),
  text('credits', '{attr.credits}', LABEL, 9.5, 'muted', at('#title', 'below', 0, 1.5, MEASURE),
    { fontWeight: 500 }),
] } });
const foot = (id, content, parity, edge, x, more = {}) => text(id, content, LABEL, 7.5, 'muted',
  at('page', edge, x, TRIM.h - 13), { ...caps(7.5), parity, pages: 'all', ...more });
const footer = { elements: [ // folios at the outer foot, the programme's name beside them
  foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }),
  foot('verso-title', '{title}', 'even', 'top-left', OUTER + 7),
  foot('recto-title', '{title}', 'odd', 'top-right', -(OUTER + 7), { align: 'right' }),
  foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER,
    { color: col('ink'), align: 'right' }),
] };
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), colorPalette, videoStyle,
  resourceTypes: defaultResourceTypes(LANG).map((ty) => (ty.id === 'video' ? filmType(ty) : ty)),
  page: { sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(24), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    // The dictionary divided Áms-terdam, Blen-der and You-Tube: the list keeps them whole.
    hyphenation: { enabled: true, exceptions: ['Ámsterdam', 'Blender', 'YouTube'] },
    optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('ink'), levels: [
    // parity 'any': each film opens the next page, with no blank page before it.
    { level: 1, fontSize: pt(34), breakBefore: { enabled: true, parity: 'any' },
      marginBottom: pt(0), advancedDesign: opener() }, // minHeight alone sets the gap
    { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), textTransform: 'uppercase',
      letterSpacing: pt(0.6), marginTop: pt(LEAD), marginBottom: pt(0) },
  ] },
  headingStyles: [coverStyle()],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col('ink'), gap: pt(5),
    labelBold: true, labelColor: col('accent'),
    note: { fontSize: pt(7.5), color: col('muted') } },
  paragraphStyles: [ // ragged and unhyphenated, so no web address breaks
    { id: 'credits', fontFamily: LABEL, fontWeight: 500, fontSize: pt(9), lineHeight: pt(12),
      color: col('ink'), boldColor: col('ink'), textAlign: 'left', hyphenation: false,
      firstLineIndent: pt(0), spaceBetween: pt(3) },
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'),
      textAlign: 'left', hyphenation: false, firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lumen Film Club · Open Movies"
author: "Lumen Film Club"
---

# Open Movies {style="cover" kicker="Lumen Film Club · Autumn 2026" standfirst="Four short films made in the open, one for each Friday in November" dates="Fridays at 20:30 · Studio 2, Harbour Arts Centre"}

Every film in this season was made in the open. The Blender Foundation, the Dutch non-profit behind the free 3D software Blender, has produced short films since 2006 to test and improve its own tools, and it publishes each one under a Creative Commons licence together with the files used to make it. We chose four of them: Elephants Dream, the first, from 2006 (:ref{id="elephants-dream"}); Big Buck Bunny, from 2008 (:ref{id="big-buck-bunny"}); Tears of Steel, shot with actors in Amsterdam in 2012 (:ref{id="tears-of-steel"}); and Spring, from 2019 (:ref{id="spring"}).

None of them runs longer than twelve minutes, so each evening pairs one film with half an hour of conversation. Every film page carries a title card with a code in its corner. Point a phone camera at the code and the film opens on YouTube; in the PDF of this programme the card itself is the link. If you miss a Friday, watch at home and come to the next one.

# Elephants Dream {kicker="Session 1 · Friday 6 November" credits="Directed by Bassam Kurdali · Netherlands, 2006 · 11 min · English"}

::resource{id="elephants-dream"}

Emo, a young man, follows the older Proog through a machine that seems to have no end: rooms of switchboards and cables, a flock of mechanical birds, a lift thrown upwards through a row of shutters. Proog believes in the machine and wants Emo to admire it. Emo does not see what Proog sees, and the film leaves the question open on purpose.

Elephants Dream was made in Amsterdam from September 2005 by a team of seven artists, under the code name Project Orange, with money from DVD pre-sales and the Netherlands Media Art Institute. When it came out in March 2006 the production files went online with it, so anyone could study how each shot was built. The voices are Cas Jansen and Tygo Gernandt, and the score is by Jan Morgenstern.

# Big Buck Bunny {kicker="Session 2 · Friday 13 November" credits="Directed by Sacha Goedegebure · Netherlands, 2008 · 10 min · No dialogue"}

::resource{id="big-buck-bunny"}

A large and gentle rabbit spends his morning watching butterflies until three rodents, led by a flying squirrel, start pelting him with fruit and kill two of the butterflies. He answers with a series of traps. Nobody speaks: the story is told in timing and gesture, as in the cartoon shorts it remembers.

Project Peach brought seven artists to the Blender Institute in Amsterdam from October 2007 to April 2008, and the film had its premiere there on 10 April 2008. The fur, hair and grass of the forest pushed the software forward, and the new features reached everyone in Blender 2.46. Because it is bright, varied and free to use, Big Buck Bunny is also a standard test clip for video players, so you may have seen it before without knowing its name.

# Tears of Steel {kicker="Session 3 · Friday 20 November" credits="Written and directed by Ian Hubert · Netherlands, 2012 · 12 min · English"}

::resource{id="tears-of-steel"}

Forty years after a young couple break up on a bridge in Amsterdam, the robots built by one of them have taken over the city. A team of scientists gathers in the Oude Kerk to stage that moment again and change how it ends. It is the only live-action film of the season: the actors were filmed in Amsterdam, and the sets, the robots and the battle were added in Blender.

Project Mango ran through 2012 to improve Blender’s visual-effects tools, such as camera tracking and compositing, and part of its money came from the Netherlands Film Fund. The film was released online on 26 September 2012. Derek de Lint plays the older Thom, and Joris Kerbosch shot it. Stay for the credits, which the makers ask every screening to keep.

# Spring {kicker="Session 4 · Friday 27 November" credits="Written and directed by Andy Goralczyk · Netherlands, 2019 · 8 min · No dialogue"}

::resource{id="spring"}

A shepherd girl and her dog climb down from the sunlit heights into a frozen forest, where giant spirits hold back the change of season. With her staff and a chime she has to lead them out of the valley, so that the ice can melt. Nobody speaks; the music of Torin Borrowdale carries the story from the first shot to the last.

Andy Goralczyk, who had animated on Elephants Dream thirteen years earlier, wrote and directed the film, drawing on his childhood in the mountains of Germany. The team started in Blender 2.79 and moved early to 2.80, then still in beta, so Spring served as a test of that release. It had its premiere on YouTube on 4 April 2019, and its characters and files are published under CC BY 4.0.

# At the Club {kicker="Practical notes" credits="Studio 2, Harbour Arts Centre · Doors open at 20:00"}

Screenings start at 20:30. Entry is free for members and €3 for guests; membership costs €15 a year and can be paid at the door. The studio seats sixty, and the conversation after each film lasts about half an hour.

## Why we can show these films

Each film is published under a Creative Commons Attribution licence, which lets anyone copy, share and screen the work, in public and with an entry fee, as long as the makers are credited. The Blender Foundation asks that the credit roll stay at the end of each film, and we keep it.

## Watching at home

The codes on the title cards open the films on Blender’s YouTube channels. If your phone does not read a code, type the short address printed under the picture. The PDF of this programme, on the club’s website, links each card to its film.

## Credits

:::paragraphs{style="credits"}
**Elephants Dream** © 2006 Blender Foundation and Netherlands Media Art Institute · CC BY 2.5 · orange.blender.org

**Big Buck Bunny** © 2008 Blender Foundation · CC BY 3.0 · peach.blender.org

**Tears of Steel** © 2012 Blender Foundation · CC BY 3.0 · mango.blender.org

**Spring** © 2019 Blender Foundation (Blender Studio) · CC BY 4.0 · studio.blender.org
:::

:::paragraphs{style="colophon"}
Set in Literata, Big Shoulders Display and Barlow Semi Condensed (SIL Open Font License) · Text and title cards: original, CC BY 4.0 · Films: Blender Foundation, CC BY.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a title card per film, drawn on a canvas with the page's own fonts
// The 2D canvas paints the motifs and sets the type in the faces the page has loaded.
// Seeded, so every run draws the same cards.
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
  * (1 - k) + parseInt(b.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`;
const MOTIFS = {
  gears(g, rand, bg) { // a machine without end: three gears and the cables between them
    const copper = '#d98a45';
    g.lineWidth = 4;
    for (let i = 0; i < 7; i++) {
      g.strokeStyle = `${copper}88`;
      g.beginPath();
      g.moveTo(0, 90 + rand() * 380);
      g.bezierCurveTo(300 + rand() * 300, rand() * 500, 600 + rand() * 200, 100 + rand() * 400,
        1000 + rand() * 300, 120 + rand() * 300);
      g.stroke();
    }
    for (const [x, y, r, teeth] of [[1150, 300, 215, 18], [835, 160, 112, 11], [905, 455, 88, 9]]) {
      g.beginPath();
      for (let k = 0; k <= teeth * 4; k++) {
        const a = (k / (teeth * 4)) * Math.PI * 2;
        const rr = k % 4 < 2 ? r : r * 0.84;
        g.lineTo(x + rr * Math.cos(a), y + rr * Math.sin(a));
      }
      g.fillStyle = mix(bg, '#000000', 0.25);
      g.fill();
      g.lineWidth = 6;
      g.strokeStyle = copper;
      g.stroke();
      g.beginPath();
      g.arc(x, y, r * 0.3, 0, Math.PI * 2);
      g.stroke();
    }
  },
  meadow(g, rand, bg) { // a peach sun, three hills, butterflies
    g.fillStyle = '#f5b98a';
    g.beginPath();
    g.arc(1180, 250, 165, 0, Math.PI * 2);
    g.fill();
    for (let i = 0; i < 6; i++) { // butterflies: two pairs of wings each
      const [x, y, s] = [760 + rand() * 640, 90 + rand() * 300, 14 + rand() * 12];
      g.fillStyle = i % 2 ? '#f6f1e7' : '#e9b44c';
      for (const side of [-1, 1]) {
        g.beginPath();
        g.moveTo(x, y);
        g.lineTo(x + side * s * 1.4, y - s);
        g.lineTo(x + side * s * 1.1, y + s * 0.7);
        g.fill();
      }
    }
    [[470, 0.15], [560, 0.35], [650, 0.55]].forEach(([base, dark], layer) => {
      g.fillStyle = mix(bg, '#000000', dark);
      g.beginPath();
      g.moveTo(0, CARD.h);
      for (let x = 0; x <= CARD.w; x += 20) {
        g.lineTo(x, base + 60 * Math.sin(x / (260 + layer * 90) + layer * 2));
      }
      g.lineTo(CARD.w, CARD.h);
      g.fill();
    });
  },
  city(g, rand, bg) { // a skyline, one church spire, a ringed moon
    g.strokeStyle = '#e9b44c';
    g.lineWidth = 5;
    g.beginPath();
    g.arc(1240, 210, 105, 0, Math.PI * 2);
    g.stroke();
    g.beginPath();
    g.ellipse(1240, 210, 190, 34, -0.3, 0, Math.PI * 2);
    g.stroke();
    const shade = mix(bg, '#000000', 0.45);
    for (let x = 520; x < CARD.w; x += 46 + rand() * 60) {
      const [w, h] = [40 + rand() * 70, 90 + rand() * 300];
      g.fillStyle = shade;
      g.fillRect(x, 560 - h, w, CARD.h);
      g.fillStyle = '#e9b44c';
      for (let wy = 580 - h; wy < 540; wy += 26) {
        if (rand() < 0.18) g.fillRect(x + 8 + rand() * (w - 22), wy, 8, 12);
      }
    }
    g.fillStyle = shade; // the Oude Kerk's spire stands over the rest
    g.beginPath();
    g.moveTo(940, 560);
    g.lineTo(1000, 90);
    g.lineTo(1060, 560);
    g.fill();
    g.fillRect(0, 560, CARD.w, CARD.h);
  },
  ridges(g, rand, bg) { // misty ridges, dark firs and one red light
    g.fillStyle = mix(bg, '#f6f1e7', 0.55);
    g.beginPath();
    g.arc(1260, 170, 85, 0, Math.PI * 2);
    g.fill();
    [[300, 0.35], [400, 0.15], [500, -0.15], [600, -0.4]].forEach(([base, k]) => {
      g.fillStyle = k > 0 ? mix(bg, '#f6f1e7', k) : mix(bg, '#000000', -k);
      g.beginPath();
      g.moveTo(0, CARD.h);
      for (let x = 0; x <= CARD.w; x += 80) g.lineTo(x, base - rand() * 110);
      g.lineTo(CARD.w, CARD.h);
      g.fill();
    });
    g.fillStyle = mix(bg, '#000000', 0.6);
    for (let i = 0; i < 9; i++) {
      const [x, h] = [920 + i * 58 + rand() * 30, 130 + rand() * 120];
      g.beginPath();
      g.moveTo(x - h * 0.22, 640);
      g.lineTo(x, 640 - h);
      g.lineTo(x + h * 0.22, 640);
      g.fill();
    }
    g.fillStyle = '#e0452c';
    g.beginPath();
    g.arc(640, 500, 11, 0, Math.PI * 2);
    g.fill();
  },
};
async function titleCard(f) {
  const canvas = Object.assign(document.createElement('canvas'), { width: CARD.w, height: CARD.h });
  const g = canvas.getContext('2d');
  const bg = palette[f.colour];
  g.fillStyle = bg;
  g.fillRect(0, 0, CARD.w, CARD.h);
  MOTIFS[f.motif](g, mulberry32(f.year), bg);
  // Lower left, clear of the play mark (top left) and the QR plate (bottom right).
  g.fillStyle = '#e9b44c';
  g.font = `600 34px "${LABEL}"`;
  g.letterSpacing = '7px';
  g.fillText(`${t({ en: 'Blender open movie', es: 'Película abierta de Blender' })} · ${f.year}`
    .toUpperCase(), 96, 612);
  g.letterSpacing = '0px';
  g.fillStyle = '#f6f1e7';
  let size = 154;
  do g.font = `800 ${size -= 4}px "${DISPLAY}"`;
  while (g.measureText(f.title.toUpperCase()).width > 1080);
  g.fillText(f.title.toUpperCase(), 92, 760);
  g.font = `500 40px "${LABEL}"`;
  g.fillText(`${t({ en: 'Directed by', es: 'Dirigida por' })} ${f.director}`, 96, 826);
  const blob = await new Promise((done) => canvas.toBlob(done, 'image/png'));
  await loadImage(`${f.id}.png`, URL.createObjectURL(blob)); // the canvas and the PDF
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages and the cards use
  Literata: ['400', '400i'], // the programme notes, the cover's standfirst
  'Big Shoulders Display': ['700', '800'], // titles, on the pages and the cards
  'Barlow Semi Condensed': ['500', '600', '700'], // kickers, credits, captions, folios
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await prepareFonts(markdown, config(), kitFonts(FONTS)); // before the cards: they set type too
for (const f of FILMS) await titleCard(f);
const resources = FILMS.map(film);
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Film club programme', es: 'Programa de cineclub' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${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

### Das eigene Bild des Films drucken

Holen Sie statt einer gezeichneten Karte das größte Vorschaubild von YouTube, wenn Lizenz des Films und Ihr Host das erlauben, und geben Sie die Größe dieses Bildes an.

```diff
-for (const f of FILMS) await titleCard(f); // after the fonts: the cards set type too
+for (const f of FILMS) { // 1280 × 720, maxresdefault.jpg, the first of youtubePosterUrls
+  await loadImage(`${f.id}.png`, youtubePosterUrls(parseVideoUrl(f.address).id)[0]);
+}
```

### Die Filme in der HTML-Ausgabe abspielen

`renderToHtml` setzt jedes Video als datenschutzfreundlichen Player von YouTube, und ein EPUB zeigt das mit dem Film verlinkte Vorschaubild; `videoStyle.html: 'poster'` behält die gedruckte Karte auch am Bildschirm. Siehe [Videos](https://postext.dev/de/docs/document-format.md#videos) und [Videoformat](https://postext.dev/de/docs/configuration-resources.md#videostil).

### Ein größeres Abspielzeichen zentrieren

Ein Zeichen in der Mitte der Karte wird quer durch den Raum als Videoplayer erkannt; die Karten halten ihr mittleres Band frei von Schrift, sodass es nur das Motiv verdeckt.

```diff
-  playMark: { shape: 'rounded', position: 'top-left', size: mm(8), inset: mm(4),
+  playMark: { shape: 'rounded', position: 'center', size: mm(14), inset: mm(4),
```

## Häufige Fehler

- **Bitmaps beziehen ihre Druckgröße aus einer Auflösung: diese angeben, nicht Pixel in Druckgröße.** Eine Bitmap ohne Auflösung wird mit einem Pixel pro Layoutpixel bei der dpi des Dokuments gesetzt, sodass ein Foto mit 2400 px auf einer Seite mit 150 dpi 406 mm breit ist. Geben Sie die echten Pixel der Datei an und dem Bild seine Auflösung (bitmap.resolution: 300), oder setzen Sie layout.bitmapResolution für jede Bitmap: einen ppi-Wert oder 'file', um den in der Datei angegebenen zu lesen. Die Spalte begrenzt das Bild weiterhin.
- **::resource{id="…"} nimmt nur doppelte Anführungszeichen.** Eine Block-Einbettung wird nur als ::resource{id="…"} mit doppelten Anführungszeichen erkannt; jede andere Form bleibt als sichtbare Zeile im Text.
- **Layout-Warnung: Video ohne Vorschaubild** (`videoWithoutPoster`). Eine Videoressource, die der Text verwendet, hat kein `video.poster`, der Druck zeigt daher einen dunklen Kasten im Seitenverhältnis des Videos mit Wiedergabesymbol und QR-Code. Lösung: Geben Sie dem Video ein Vorschaubild: ein Einzelbild des Videos oder ein eigenes Bild (`{ fileId, format, width, height }`), und registrieren Sie seine Bitmap wie jedes Bild.

- Die Engine begrenzt den Code auf 45 % der kürzeren Seite des Vorschaubilds und das Abspielzeichen auf 40 %. Auf einem kleinen Vorschaubild wird ein Code von 20 mm kleiner und seine Module mit ihm: Ein Code von 20 mm braucht ein Vorschaubild von mindestens 45 mm Höhe.
- Zwei Überlagerungen mit derselben Position verdecken einander. Geben Sie Abspielzeichen und Code verschiedene Ecken, und zeichnen Sie das Vorschaubild mit diesen Ecken frei.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: Title card for Elephants Dream, drawn in code. The film, © 2006 Blender Foundation and Netherlands Media Art Institute, CC BY 2.5, is linked from the page, not reproduced: Ignacio Ferro ([Quelle](https://studio.blender.org/films/elephants-dream/)), Original
- Bilder: Title card for Big Buck Bunny, drawn in code. The film, © 2008 Blender Foundation, CC BY 3.0, is linked from the page, not reproduced: Ignacio Ferro ([Quelle](https://studio.blender.org/films/big-buck-bunny/)), Original
- Bilder: Title card for Tears of Steel, drawn in code. The film, © 2012 Blender Foundation, CC BY 3.0, is linked from the page, not reproduced: Ignacio Ferro ([Quelle](https://studio.blender.org/films/tears-of-steel/)), Original
- Bilder: Title card for Spring, drawn in code. The film, © 2019 Blender Foundation (Blender Studio), CC BY 4.0, is linked from the page, not reproduced: Ignacio Ferro ([Quelle](https://studio.blender.org/films/spring/)), Original
- Schrift: Literata (OFL-1.1), Big Shoulders Display (OFL-1.1), Barlow Semi Condensed (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 009 · Abbildungen, die dorthin gleiten, wo Sie sie zitieren](https://postext.dev/de/cookbook/figures-float-where-cited.md): Zweispaltiges Kapitel mit sieben nummerierten Abbildungen: Sechs gleiten vom ersten :ref an den ersten erlaubten Platz, eine steht, wo ::resource sie setzt. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 068 · Fotoessay mit randabfallenden Tafeln](https://postext.dev/de/cookbook/photo-essay-full-bleed.md): Tafeln als Überschriftenformate aus einer Liste, jede füllt ihre Seite bis zum Endformat; die Gewittertafel läuft halbiert über den Bund einer Doppelseite. · Stufe 2 (Mittel) · Fotobücher
- [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
