# Programa de cineclube com QR codes nos pôsteres

> Cada filme é um recurso de vídeo cujo pôster sai impresso com uma marca de reprodução e um QR code que o abre no YouTube; no PDF, o pôster também é um link.

- Versão HTML: https://postext.dev/pt/cookbook/film-club-video-qr
- Receita Nº 131 · Figuras e imagens · Nível 2 (Intermediário) · Saídas: Canvas, PDF
- Gêneros: Folhas avulsas e impressos efêmeros, Revistas e fanzines
- Requer postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · testada com 1.19.1, postext-pdf 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/film-club-video-qr/en/p01.webp?v=fa121685), [2](https://postext.dev/cookbook/film-club-video-qr/en/p02.webp?v=fa121685), [3](https://postext.dev/cookbook/film-club-video-qr/en/p03.webp?v=fa121685), [4](https://postext.dev/cookbook/film-club-video-qr/en/p04.webp?v=fa121685), [5](https://postext.dev/cookbook/film-club-video-qr/en/p05.webp?v=fa121685), [6](https://postext.dev/cookbook/film-club-video-qr/en/p06.webp?v=fa121685)
- PDF: https://postext.dev/cookbook/film-club-video-qr/en/film-club-video-qr.pdf?v=fa121685
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=film-club-video-qr&lang=en (.postext: https://postext.dev/cookbook/film-club-video-qr/en/film-club-video-qr.postext)
- Última atualização: 2026-10-05
- Outros idiomas: [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), [ja](https://postext.dev/ja/cookbook/film-club-video-qr.md), [ar](https://postext.dev/ar/cookbook/film-club-video-qr.md)

## Em poucas palavras

Um programa de seis páginas para a temporada de outono de um cineclube, com quatro curtas. Cada página de filme traz uma imagem com um código que o celular lê para ver o filme, e no PDF basta clicar na imagem.

## O que você vai compor

O programa de outono do Lumen Film Club, um livreto de 165 × 235 mm em papel creme: uma capa com faixa preta de tela e o título da temporada em Big Shoulders Display, e depois uma página para cada um de quatro filmes abertos da Blender Foundation. Cada página de filme começa com um chapéu vermelho com a sessão, o título em maiúsculas condensadas e uma linha de créditos, seguidos de uma cartela na medida inteira de 125 mm. Uma marca de reprodução vermelha fica no canto superior esquerdo da cartela, e um QR code de 20 mm no inferior direito. O sócio que perdeu uma sexta-feira aponta o celular para o código e o filme abre no YouTube; no PDF, a cartela inteira é o link. O endereço curto embaixo de cada cartela serve para o celular que não conseguir ler o código.

**Esta receita responde a:**

- Como imprimo um vídeo num programa para o leitor abri-lo lendo o código na página ou clicando no PDF?

## A resposta curta

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

## Ingredientes

**Ensina**

- [Vídeos como recursos](https://postext.dev/pt/docs/document-format.md#vídeos): Vídeos do YouTube, do Vimeo ou hospedados por você, posicionados, legendados e numerados como figuras (Vídeo 1.1); o impresso mostra a imagem de capa, e o visualizador HTML e o EPUB reproduzem o vídeo.
- [Capas de vídeo: marca de reprodução e código QR](https://postext.dev/pt/docs/configuration.md#estilo-de-vídeo): videoStyle: a marca de reprodução e o código QR impressos sobre a imagem de capa de um vídeo, o link da capa e as opções do player nas saídas HTML e EPUB.

**Também usa**

- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Legendas numeradas](https://postext.dev/pt/docs/document-format.md#numeração-pela-primeira-referência)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Linhas de fonte e crédito](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Capítulos sem número](https://postext.dev/pt/docs/configuration.md#estilos-de-título)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`videoStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-vídeo)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`decompressWoff2`](https://postext.dev/pt/docs/configuration.md#provedor-de-fontes-no-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`parseVideoUrl`](https://postext.dev/pt/docs/document-format.md#vídeos), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap), [`renderToPdf`](https://postext.dev/pt/docs/configuration.md#geração-de-pdf), [`videoWatchUrl`](https://postext.dev/pt/docs/document-format.md#vídeos)

**Tipos**

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

## Preparo

### 1 · Cada filme é um recurso de vídeo

O código está na [resposta curta](https://postext.dev/pt/cookbook/film-club-video-qr.md#a-resposta-curta), mais acima. Um recurso de vídeo é posicionado, legendado e numerado como uma figura, e o impresso o mostra como seu pôster com duas sobreposições. A marca de reprodução e o código vão para cantos opostos e nunca se encontram, e o pôster, com 70 mm de altura, deixa espaço para os dois. O código tem 20 mm com uma zona de silêncio de dois módulos: `https://youtu.be/` mais um identificador de onze caracteres dá 29 módulos no nível Q, de modo que cada módulo mede 0,61 mm, cerca do dobro do que a câmera de um celular precisa. Q é o nível mais alto que mantém 29 módulos com esse comprimento (o motor sobe de M para ele por conta própria); H exigiria 33 e reduziria os módulos a 0,54 mm. O tipo `video` embutido imprimiria *Video 1.1*; renomeado para *Film* e contado com `{n}` no documento inteiro, ele numera as quatro sessões da temporada de *Film 1* a *Film 4*, e as citações da capa imprimem as mesmas palavras.

### 2 · Escreva o endereço curto e deixe o motor completá-lo

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

O endereço de cada entrada é o que sai impresso embaixo da cartela, `youtu.be/TLkA0RELQ1g`, que o leitor consegue digitar. `parseVideoUrl` lê qualquer forma de endereço do YouTube ou do Vimeo, e `videoWatchUrl` escreve o endereço canônico a partir do que foi lido: o QR code e o link do PDF guardam os dois `https://youtu.be/<id>`. Um erro de digitação que não deixe um identificador válido faz `parseVideoUrl` devolver `undefined`, e o pen para antes da composição, então um link quebrado nunca chega à gráfica.

### 3 · Desenhe uma cartela para o pôster

```js
// script.js, linhas 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
}
```

Na maioria dos projetos o pôster é um quadro do filme: o Sandbox o busca quando você informa o endereço, e no código `youtubePosterUrls(id)` lista as miniaturas do YouTube da maior (1280 × 720) para baixo. Os quadros das plataformas pertencem aos filmes e às suas licenças, e um programa impresso pode usar cartelas próprias, por isso cada filme ganha aqui uma desenhada num canvas na sua cor, com o motivo, o título, o ano e o diretor do filme. A cartela deixa livres os cantos superior esquerdo e inferior direito para as sobreposições. É um canvas e não um SVG porque um SVG desenhado como imagem não alcança as fontes web da página, e as cartelas compõem o texto nas fontes do livreto. Cada cartela tem 1600 × 900 pixels, o 16:9 dos filmes, e `video.poster` declara esse tamanho: na medida de 125 mm ela sai impressa a 325 dpi. Um vídeo sem pôster sai como uma caixa escura em 16:9 com a mesma marca e o mesmo código, e a composição emite um aviso.

### 4 · Ponha o pôster embaixo da abertura

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

Cada filme é um título de nível 1 cuja abertura empilha o chapéu da sessão, o título e a linha de créditos, os três vindos de atributos do título. `::resource` coloca o pôster logo abaixo com `position: 'here'`, e assim todas as páginas de filme seguem a mesma ordem. A capa é um estilo de título cuja faixa, título e chips são elementos de design; os quatro chips tiram da paleta as cores dos filmes, como fazem as cartelas.

### 5 · Uma paleta para as páginas e as cartelas

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

A tinta, o vermelho e o creme são os do livreto; as quatro cores dos filmes (com os nomes dos projetos Orange, Peach e Mango da Blender, mais Spring) pintam os chips da capa e as cartelas. A marca de reprodução usa o mesmo vermelho dos chapéus, e a placa do QR code é o creme da página com os módulos em tinta: um código escuro sobre uma placa clara, o contraste que todo aplicativo leitor espera.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  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 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 = `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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** 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, '-'); }

/** 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 v1 ── 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. */
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 v2 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType: the nearest weight the
 *  family ships, upright if it has no italic; latin, then what the face's
 *  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 more = kitSubsetsFor(text, meta);
  const files = await Promise.all(['latin', ...more].map(async (subset) => {
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
  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 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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Imprima o quadro do próprio filme

Busque a maior miniatura do YouTube em vez de desenhar uma cartela, quando a licença do filme e a sua hospedagem permitirem, e declare o tamanho do próprio quadro.

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

### Reproduza os filmes na edição HTML

`renderToHtml` põe em cada vídeo o player do YouTube com privacidade aprimorada, e um EPUB mostra o pôster com link para o filme; `videoStyle.html: 'poster'` mantém a cartela impressa também na tela. Veja [Vídeos](https://postext.dev/pt/docs/document-format.md#vídeos) e [Estilo de vídeo](https://postext.dev/pt/docs/configuration.md#estilo-de-vídeo).

### Centralize uma marca maior

Uma marca no meio da cartela é reconhecida como player de vídeo do fundo da sala; as cartelas deixam sem texto a faixa do meio, então ela cobre só o motivo.

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

## Erros comuns

- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **Bitmaps são diagramados em px na resolução do documento: declare o tamanho de impressão.** Um recurso bitmap é dimensionado pela largura e pela altura que declara, em pixels na resolução (dpi) do documento, não pelo arquivo. Declare os pixels do tamanho de impressão (cerca de 300 dpi na largura impressa) para que a figura saia no tamanho certo e nítida.
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **::resource{id="…"} só aceita aspas duplas.** Uma inserção em bloco só é reconhecida como ::resource{id="…"} com aspas duplas; qualquer outra forma fica no texto como uma linha visível.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **O excesso de texto de design é 'ellipsis-end' por padrão.** Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.
- **Aviso de diagramação: Vídeo sem imagem de capa** (`videoWithoutPoster`). Um recurso de vídeo usado no texto não tem `video.poster`, então o impresso mostra uma caixa escura na proporção do vídeo, com o símbolo de reprodução e o QR code. Solução: Dê ao vídeo uma imagem de capa, um quadro do próprio vídeo ou uma imagem sua (`{ fileId, format, width, height }`), e registre o bitmap dela como o de qualquer imagem.

- O motor limita o código a 45 % do lado menor do pôster e a marca de reprodução a 40 %. Num pôster pequeno, um código de 20 mm fica menor, e seus módulos também: um código de 20 mm precisa de um pôster com pelo menos 45 mm de altura.
- Duas sobreposições com a mesma posição se cobrem. Dê à marca de reprodução e ao código cantos diferentes, e desenhe o pôster com esses cantos livres.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imagens: 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 ([fonte](https://studio.blender.org/films/elephants-dream/)), original
- Imagens: 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 ([fonte](https://studio.blender.org/films/big-buck-bunny/)), original
- Imagens: 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 ([fonte](https://studio.blender.org/films/tears-of-steel/)), original
- Imagens: 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 ([fonte](https://studio.blender.org/films/spring/)), original
- Tipos: Literata (OFL-1.1), Big Shoulders Display (OFL-1.1), Barlow Semi Condensed (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 009 · Figuras que flutuam até onde você as cita](https://postext.dev/pt/cookbook/figures-float-where-cited.md): Capítulo em duas colunas com sete figuras numeradas: seis flutuam do primeiro :ref até o primeiro espaço que a posição permite; uma fica onde ::resource a põe. · Nível 3 (Avançado) · Livros didáticos
- [Nº 068 · Ensaio fotográfico com pranchas sangradas](https://postext.dev/pt/cookbook/photo-essay-full-bleed.md): As pranchas são estilos de título gerados a partir de uma lista, cada uma enchendo a página até o refile; a da tempestade cruza a medianiz em duas metades. · Nível 2 (Intermediário) · Fotolivros
- [Nº 051 · Zine riso em duas cores: a arte numa cor especial](https://postext.dev/pt/cookbook/riso-zine-single-ink.md): Um zine de quatro páginas para risografia de dois tambores: os desenhos coloridos saem em tons de rosa, recoloridos pelo pen no canvas e por renderToPdf no PDF. · Nível 3 (Avançado) · Revistas e fanzines
