# ポスター画像にQRコードを刷った映画クラブのプログラム

> 1本の映画を1つの動画リソースにします。ポスター画像には再生マークと、YouTubeで映画を開くQRコードを刷り、PDFではポスター画像そのものもリンクになります。

- HTML版: https://postext.dev/ja/cookbook/film-club-video-qr
- レシピ No. 131 · 図と画像 · 難易度 2 (中級) · 出力: Canvas, PDF
- ジャンル: 一枚物とエフェメラ, 雑誌とジン
- 必要なもの postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · テスト環境 1.16.1, postext-pdf 1.16.1 ／テスト日 2026-10-05
- ページ: [1](https://postext.dev/cookbook/film-club-video-qr/en/p01.webp?v=4d71815e), [2](https://postext.dev/cookbook/film-club-video-qr/en/p02.webp?v=4d71815e), [3](https://postext.dev/cookbook/film-club-video-qr/en/p03.webp?v=4d71815e), [4](https://postext.dev/cookbook/film-club-video-qr/en/p04.webp?v=4d71815e), [5](https://postext.dev/cookbook/film-club-video-qr/en/p05.webp?v=4d71815e), [6](https://postext.dev/cookbook/film-club-video-qr/en/p06.webp?v=4d71815e)
- PDF: https://postext.dev/cookbook/film-club-video-qr/en/film-club-video-qr.pdf?v=4d71815e
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=film-club-video-qr&lang=en (.postext: https://postext.dev/cookbook/film-club-video-qr/en/film-club-video-qr.postext)
- 最終更新: 2026-10-05
- 他の言語: [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), [zh](https://postext.dev/zh/cookbook/film-club-video-qr.md), [ar](https://postext.dev/ar/cookbook/film-club-video-qr.md)

## かんたんな説明

映画クラブの秋のシーズンに上映する短編4本を紹介する、6ページのプログラムです。映画ごとのページの絵には、スマートフォンで読み取ると映画を見られるコードが付き、PDFではその絵をクリックできます。

## できあがり

Lumen Film Clubの秋のプログラムです。クリーム色の紙に組んだ165 × 235 mmの小冊子で、表紙はスクリーンの黒の帯にBig Shoulders Displayでシーズンの題名を組み、続いてBlender Foundationのオープンムービー4本に1ページずつを充てます。映画のページは、上映回を示す赤のキッカー、コンデンス体の大文字の題名、クレジットの行で始まり、その下に125 mmの行長いっぱいのタイトルカードが続きます。カードの左上の角には赤い再生マーク、右下には20 mmのQRコードがあります。金曜の上映を見逃した会員がスマートフォンをコードに向けると、YouTubeで映画が開きます。PDFではカード全体がリンクです。各カードの下の短いアドレスは、コードを読み取れないスマートフォンのためのものです。

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

- 読者がページから読み取ったりPDFでクリックしたりできるように、プログラムに動画を刷るには？

## 手短な答え

```js
// script.js, 行 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' });
```

## 材料

**学べること**

- [リソースとしての動画](https://postext.dev/ja/docs/document-format.md#動画): YouTube、Vimeo、または自前でホストした動画を、図と同じように配置し、キャプションと番号（動画1.1）を付けます。印刷ではポスター画像を表示し、HTMLビューアーとEPUBでは再生します。
- [動画のポスター：再生マークとQRコード](https://postext.dev/ja/docs/configuration.md#動画スタイル): videoStyleは、動画のポスターに印刷する再生マークとQRコード、ポスターのリンク、HTMLとEPUBの出力のプレーヤーの設定を決めます。

**ほかに使うもの**

- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [番号付きキャプション](https://postext.dev/ja/docs/document-format.md#初出順の番号付け)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [その場に置く図](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [出典とクレジット](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`videoStyle`](https://postext.dev/ja/docs/configuration.md#動画スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`parseVideoUrl`](https://postext.dev/ja/docs/document-format.md#動画), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成), [`videoWatchUrl`](https://postext.dev/ja/docs/document-format.md#動画)

**書体**

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

## 作り方

### 1 · 映画ごとに動画リソースを作る

コードは前の[手短な答え](#手短な答え)にあります。動画リソースは図と同じように配置、キャプション付け、番号付けされ、印刷ではポスター画像に2つのオーバーレイを重ねて表示されます。再生マークとコードは対角の隅に置くので重なることはなく、高さ70 mmのポスター画像なら両方を置く余地があります。コードは20 mmで、クワイエットゾーンは2モジュールです。`https://youtu.be/`に11文字のIDを足すと、レベルQで29モジュールになるので、1モジュールは0.61 mm、スマートフォンのカメラが必要とするおよそ2倍の大きさです。この長さで29モジュールを保てる最も高いレベルがQです（既定のMは、エンジンが自動的にQまで引き上げます）。Hにすると33モジュールになり、モジュールは0.54 mmに縮みます。組み込みの`video`の種類は*Video 1.1*と刷りますが、名前を*Film*に変えて`{n}`で文書全体を通して数えると、シーズンの4回の上映に*Film 1*から*Film 4*と番号が付き、表紙の引用も同じ語を刷ります。

### 2 · アドレスは短く書き、展開はエンジンに任せる

```js
// script.js, 行 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.' } },
];
```

各項目のアドレスは、カードの下に刷られる`youtu.be/TLkA0RELQ1g`で、読者が手で入力できる形です。`parseVideoUrl`はYouTubeやVimeoのどの形式のアドレスも読み、`videoWatchUrl`は読み取った内容から正規のアドレスを書き出します。QRコードとPDFのリンクは、どちらも`https://youtu.be/<id>`を持ちます。入力ミスで有効なIDが残らないと`parseVideoUrl`は`undefined`を返し、サンプルは組版の前に止まります。そのため壊れたリンクが印刷所に届くことはありません。

### 3 · ポスター画像としてタイトルカードを描く

```js
// script.js, 行 247–393
// A canvas, not an SVG: an SVG drawn as an image cannot reach the page's web fonts
// (gotcha: svg-no-webfonts). 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] = [760 + i * 70 + 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
}
```

たいていのプロジェクトでは、ポスター画像は映画の1フレームです。Sandboxはアドレスを入力したときにそれを取得し、コードでは`youtubePosterUrls(id)`がYouTubeのサムネイルを最大のもの（1280 × 720）から順に並べます。プラットフォームのフレームは映画とそのライセンスに属するもので、印刷するプログラムには独自のタイトルカードを使えます。そこでここでは映画ごとに、その映画の色でキャンバスに1枚描き、映画のモチーフ、題名、公開年、監督を入れます。カードは左上と右下の隅をオーバーレイのために空けておきます。SVGではなくキャンバスを使うのは、画像として描いたSVGはページのWebフォントを使えず、カードの文字は小冊子の書体で組むからです。カードは1600 × 900ピクセルで、映画と同じ16:9です。`video.poster`がこのサイズを宣言し、125 mmの行長では325 dpiで印刷されます。ポスター画像のない動画は、同じマークとコードを載せた16:9の暗い枠として印刷され、組版時に警告が出ます。

### 4 · ポスター画像を見出しのすぐ下に置く

```js
// script.js, 行 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 }); // wrap, never '…' (gotcha: overflow-ellipsis-default)
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' }),
] };
```

映画はそれぞれレベル1の見出しで、その見出しの扉部分には上映回のキッカー、題名、クレジットの行が積み重なります。3つとも見出しの属性から取ります。`::resource`は`position: 'here'`でポスター画像をそのすぐ下に置くので、どの映画のページも同じ順になります。表紙は見出しスタイルで、帯、題名、チップはデザイン要素です。4つのチップは、タイトルカードと同じく、映画の色をパレットから取ります。

### 5 · ページとカードに共通のパレット

```js
// script.js, 行 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' } },
];
```

墨、赤、クリーム色は小冊子の色です。4つの映画の色（BlenderのプロジェクトOrange、Peach、Mangoと、Springにちなんで名付けたもの）は、表紙のチップとカードの両方を塗ります。再生マークはキッカーと同じ赤で、QRコードの下地はページのクリーム色、モジュールは墨です。明るい下地に暗いコードという、どのリーダーアプリも前提とするコントラストです。

## レシピの全体

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

- レシピのフォルダー: 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.16.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, 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 }); // wrap, never '…' (gotcha: overflow-ellipsis-default)
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 = () => ({ // a factory: the engine caches resolved configs per object
  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,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('ink'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { 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
// A canvas, not an SVG: an SVG drawn as an image cannot reach the page's web fonts
// (gotcha: svg-no-webfonts). 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] = [760 + i * 70 + 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 (gotcha: fonts-first)
  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 loadFonts(FONTS, markdown);
for (const f of FILMS) await titleCard(f); // after the fonts: the cards set type too
const resources = FILMS.map(film);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

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

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

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

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

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

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

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

## アレンジ

### 映画自身のフレームを刷る

映画のライセンスと利用するホストが許すなら、カードを描く代わりにYouTubeの最大のサムネイルを取得し、フレーム自身のサイズを宣言します。

```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]);
+}
```

### HTML版で映画を再生する

`renderToHtml`は各動画をYouTubeのプライバシー強化モードのプレーヤーとして組み、EPUBは映画にリンクしたポスター画像を表示します。`videoStyle.html: 'poster'`を指定すると、画面でも印刷用のカードを表示します。[動画](/ja/docs/document-format#動画)と[動画スタイル](/ja/docs/configuration#動画スタイル)を参照してください。

### 大きめの再生マークを中央に置く

カードの中央に置いたマークは、部屋の向こうからでも動画プレーヤーだとわかります。カードは中央の帯に文字を置いていないので、マークが覆うのはモチーフだけです。

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

## よくあるつまずき

- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **ビットマップは文書のdpiでのpxで配置される。印刷サイズを宣言する.** ビットマップのリソースの大きさは、ファイルからではなく、宣言した幅と高さ（文書のdpiでのピクセル数）から決まります。印刷サイズのピクセル数（刷り上がりの幅で約300 dpi）を宣言すれば、図は正しい大きさに収まり、鮮明さも保たれます。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **::resource{id="…"}は二重引用符でしか書けない.** ブロックの埋め込みとして認識されるのは、二重引用符を使った::resource{id="…"}の形だけです。ほかの書き方は、目に見える1行として本文に残ります。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: ポスター画像のない動画** (`videoWithoutPoster`). 本文で使っている動画リソースに`video.poster`がないため、印刷では動画の縦横比の暗いボックスに、再生マークとQRコードだけが表示されます。 解決策: 動画にポスター画像（動画の1フレームか独自の画像、`{ fileId, format, width, height }`）を指定し、そのビットマップをほかの画像と同じように登録してください。

- エンジンはコードをポスター画像の短い辺の45%まで、再生マークを40%までに抑えます。小さなポスター画像では20 mmのコードが小さくなり、モジュールもいっしょに小さくなります。20 mmのコードには、高さ45 mm以上のポスター画像が必要です。
- 同じ位置を指定した2つのオーバーレイは重なります。再生マークとコードには別々の隅を指定し、ポスター画像はその隅を空けて描いてください。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: 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 ([出典](https://studio.blender.org/films/elephants-dream/)), オリジナル
- 画像: 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 ([出典](https://studio.blender.org/films/big-buck-bunny/)), オリジナル
- 画像: 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 ([出典](https://studio.blender.org/films/tears-of-steel/)), オリジナル
- 画像: 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 ([出典](https://studio.blender.org/films/spring/)), オリジナル
- 書体: Literata (OFL-1.1), Big Shoulders Display (OFL-1.1), Barlow Semi Condensed (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 009 · 引用した位置へフロートする図](https://postext.dev/ja/cookbook/figures-float-where-cited.md): 番号付きの図が7点ある2段組みの章。6点は最初の:refから、配置の指定が許す最初の場所へフロートし、1点は::resourceを置いた位置に入ります。 · 難易度 3 (上級) · 教科書
- [No. 068 · 裁ち落としの図版で見せる写真エッセイ](https://postext.dev/ja/cookbook/photo-essay-full-bleed.md): 図版はリストから作る見出しスタイルで、どれもページを仕上がりまで埋めます。嵐の図版は2つに分けて、見開きののどをまたがせます。 · 難易度 2 (中級) · 写真集
- [No. 051 · 2色リソのZine：図版を1色の特色で](https://postext.dev/ja/cookbook/riso-zine-single-ink.md): 2ドラムのリソグラフ用の4ページのZineです。フルカラーの線画はピンクの濃淡で印刷され、CanvasではCodePenのサンプルが、PDFではrenderToPdfが色を置き換えます。 · 難易度 3 (上級) · 雑誌とジン
