Saltar al contenido principal
Receta número 131

Recetario · Capítulo 7 · Figuras e imágenes

Programa de cineclub con códigos QR en las portadas

Cada película es un recurso de vídeo cuya portada lleva una marca de reproducción y un código QR que la abre en YouTube; en el PDF, la portada es un enlace.

En esta página

pp. 2–3 de 6

  • Formato 165 × 235 mm
  • 1 columna
  • Literata 9,8/14
  • Barlow Semi Condensed
  • Big Shoulders Display
  • 6 páginas
  • Nivel
  • Postext 1.16.1
  • Compuesto en 38 ms
  • 180 líneas de código

En pocas palabras

Un programa de seis páginas para el ciclo de otoño de un cineclub, con cuatro cortometrajes. Cada página lleva una imagen con un código que el móvil escanea para ver la película, y en el PDF basta con pulsar la imagen.

Lo que vas a componer

El programa de otoño del Cineclub Lumen, un cuadernillo de 165 × 235 mm sobre papel crema: una cubierta con una banda negro pantalla y el título del ciclo en Big Shoulders Display, y después una página para cada una de cuatro películas abiertas de la Blender Foundation. Cada página empieza con un antetítulo rojo para la sesión, el título en mayúsculas estrechas y una línea de créditos, y debajo va una cartela a todo el ancho de la caja, 125 mm. En su esquina superior izquierda hay una marca de reproducción roja, y en la inferior derecha, un código QR de 20 mm. El socio que se perdió un viernes apunta con el móvil al código y la película se abre en YouTube; en el PDF, la cartela entera es el enlace. La dirección corta del pie está para el móvil que no lea el código.

Esta receta responde a

  • ¿Cómo pongo un vídeo en un libro impreso para que el lector pueda abrirlo desde la página?

La respuesta corta

script.js · líneas 35–60en el código completo
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

Tipografía
Literata, Big Shoulders Display, Barlow Semi Condensed (SIL OFL 1.1)
Recursos
  • Cartela de Elephants Dream, dibujada en código. La película, © 2006 Blender Foundation e Instituto Neerlandés de Arte Mediático, CC BY 2.5, se enlaza desde la página, no se reproduce (Ignacio Ferro, original)
  • Cartela de Big Buck Bunny, dibujada en código. La película, © 2008 Blender Foundation, CC BY 3.0, se enlaza desde la página, no se reproduce (Ignacio Ferro, original)
  • Cartela de Tears of Steel, dibujada en código. La película, © 2012 Blender Foundation, CC BY 3.0, se enlaza desde la página, no se reproduce (Ignacio Ferro, original)
  • Cartela de Spring, dibujada en código. La película, © 2019 Blender Foundation (Blender Studio), CC BY 4.0, se enlaza desde la página, no se reproduce (Ignacio Ferro, original)

Elaboración

#1 · Cada película es un recurso de vídeo

El código está en la respuesta corta, más arriba. Un recurso de vídeo se coloca, se rotula y se numera como una figura, y el impreso lo muestra como su portada con dos elementos encima. La marca de reproducción y el código van a esquinas opuestas para no tocarse, y la portada, de 70 mm de alto, deja sitio a los dos. El código mide 20 mm con una zona de silencio de dos módulos: https://youtu.be/ más un identificador de once caracteres da 29 módulos con el nivel Q, de modo que cada módulo mide 0,61 mm, el doble de lo que necesita la cámara de un móvil. Q es el nivel más alto que conserva los 29 módulos con esta longitud (el motor sube a él desde M por su cuenta); H necesitaría 33 y dejaría los módulos en 0,54 mm. El tipo video predefinido imprimiría Vídeo 1.1; renombrado como Película y contado con {n} en todo el documento, numera las cuatro sesiones del ciclo de Película 1 a Película 4, y las citas de la cubierta imprimen las mismas palabras.

#2 · Escribe la dirección corta y deja que el motor la complete

script.js · líneas 64–85en el código completo
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.' } },
];

La dirección de cada entrada es la que va impresa bajo la cartela, youtu.be/TLkA0RELQ1g, que un lector puede teclear. parseVideoUrl lee cualquier forma de dirección de YouTube o de Vimeo, y videoWatchUrl escribe a partir de lo leído la dirección canónica: el código QR y el enlace del PDF guardan los dos https://youtu.be/<id>. Una errata que no deje un identificador válido hace que parseVideoUrl devuelva undefined, y el script se detiene antes de componer, así que un enlace roto nunca llega a la imprenta.

#3 · Dibuja una cartela como portada

script.js · líneas 247–393en el código completo
// 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
}

En la mayoría de los proyectos la portada es un fotograma de la película: el Sandbox la descarga al escribir la dirección, y en código youtubePosterUrls(id) da las miniaturas de YouTube de la mayor (1280 × 720) a la menor. Los fotogramas de las plataformas pertenecen a las películas y a sus licencias, y un programa impreso puede usar cartelas propias, así que cada película lleva aquí una dibujada en un canvas con su color, su motivo, su título, su año y su director. La cartela deja libres la esquina superior izquierda y la inferior derecha para la marca y el código. Es un canvas y no un SVG porque un SVG dibujado como imagen no llega a las fuentes web de la página, y las cartelas componen su texto con las letras del cuadernillo. Cada cartela mide 1600 × 900 píxeles, el 16:9 de las películas, y video.poster declara ese tamaño: a 125 mm de ancho se imprime a 325 ppp. Un vídeo sin portada se imprime como una caja oscura en 16:9 con la misma marca y el mismo código, y la composición avisa.

#4 · La portada va bajo la apertura

script.js · líneas 89–135en el código completo
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 película es un título de primer nivel cuya apertura apila el antetítulo de la sesión, el título y la línea de créditos, los tres tomados de atributos del título. ::resource coloca la portada justo debajo con position: 'here', de modo que todas las páginas de película siguen el mismo orden. La cubierta es un estilo de título cuyos banda, título y pastillas son elementos de diseño; las cuatro pastillas toman de la paleta los colores de las películas, igual que las cartelas.

#5 · Una paleta para las páginas y las cartelas

script.js · líneas 16–27en el código completo
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' } },
];

La tinta, el rojo y el crema son los del cuadernillo; los cuatro colores de película (con los nombres de los proyectos de Blender Orange, Peach y Mango, más Spring) pintan las pastillas de la cubierta y las cartelas. La marca de reproducción tiene el mismo rojo que los antetítulos, y la placa del QR es el crema de la página con los módulos en tinta: un código oscuro sobre una placa clara, el contraste que esperan todos los lectores de códigos.

La receta completa

Sandbox
// ═══ 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 = 'es'; // @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`---
Muestra en Markdown · 69 líneas · content.es.mdtitle: "Cineclub Lumen · Películas abiertas" author: "Cineclub Lumen" --- # Películas abiertas {style="cover" kicker="Cineclub Lumen · Otoño de 2026" standfirst="Cuatro cortometrajes hechos a la vista de todos, uno por cada viernes de noviembre" dates="Viernes a las 20.30 · Sala 2, Centro Cultural del Puerto"} Todas las películas de este ciclo se hicieron a la vista de todos. La Blender Foundation, la fundación neerlandesa sin ánimo de lucro que desarrolla el programa libre de 3D Blender, produce cortometrajes desde 2006 para poner a prueba y mejorar sus propias herramientas, y publica cada uno con una licencia Creative Commons junto con los archivos con que se hizo. Hemos elegido cuatro: Elephants Dream, la primera, de 2006 (:ref{id="elephants-dream"}); Big Buck Bunny, de 2008 (:ref{id="big-buck-bunny"}); Tears of Steel, rodada con actores en Ámsterdam en 2012 (:ref{id="tears-of-steel"}), y Spring, de 2019 (:ref{id="spring"}). Ninguna pasa de doce minutos, así que cada sesión junta una película con media hora de coloquio. Cada página lleva la cartela de su película con un código en la esquina. Apunta a él con la cámara del móvil y la película se abre en YouTube; en el PDF de este programa, la propia cartela es el enlace. Si te pierdes un viernes, mírala en casa y ven al siguiente. # Elephants Dream {kicker="Sesión 1 · Viernes 6 de noviembre" credits="Dirigida por Bassam Kurdali · Países Bajos, 2006 · 11 min · En inglés"} ::resource{id="elephants-dream"} Emo, un muchacho, sigue al viejo Proog por una máquina que parece no tener fin: salas de centralitas y cables, una bandada de pájaros mecánicos, un ascensor lanzado hacia arriba a través de una hilera de compuertas. Proog cree en la máquina y quiere que Emo la admire. Emo no ve lo que ve Proog, y la película deja la pregunta abierta a propósito. Elephants Dream se hizo en Ámsterdam a partir de septiembre de 2005, con el nombre en clave de Proyecto Orange, a cargo de un equipo de siete artistas y con el dinero de la preventa del DVD y del Instituto Neerlandés de Arte Mediático. Cuando se estrenó, en marzo de 2006, los archivos de producción se publicaron con ella, de modo que cualquiera podía estudiar cómo se había construido cada plano. Las voces son de Cas Jansen y Tygo Gernandt, y la música, de Jan Morgenstern. # Big Buck Bunny {kicker="Sesión 2 · Viernes 13 de noviembre" credits="Dirigida por Sacha Goedegebure · Países Bajos, 2008 · 10 min · Sin diálogos"} ::resource{id="big-buck-bunny"} Un conejo enorme y bonachón pasa la mañana mirando mariposas hasta que tres roedores, con una ardilla voladora a la cabeza, empiezan a tirarle fruta y matan a dos de las mariposas. Él responde con una serie de trampas. Nadie habla: la historia se cuenta con el ritmo y el gesto, como en los cortos de dibujos animados que recuerda. El Proyecto Peach reunió a siete artistas en el Blender Institute de Ámsterdam entre octubre de 2007 y abril de 2008, y la película se estrenó allí el 10 de abril de 2008. El pelaje de los animales y la hierba del bosque obligaron a mejorar el programa, y esas novedades llegaron a todos con Blender 2.46. Como es luminosa, variada y de uso libre, Big Buck Bunny es además un clip de prueba habitual para los reproductores de vídeo: puede que ya la hayas visto sin saber cómo se llamaba. # Tears of Steel {kicker="Sesión 3 · Viernes 20 de noviembre" credits="Escrita y dirigida por Ian Hubert · Países Bajos, 2012 · 12 min · En inglés"} ::resource{id="tears-of-steel"} Cuarenta años después de que una pareja joven rompa en un puente de Ámsterdam, los robots que construyó uno de los dos se han adueñado de la ciudad. Un grupo de científicos se reúne en la Oude Kerk para representar de nuevo aquel momento y cambiar su final. Es la única película de imagen real del ciclo: los actores se filmaron en Ámsterdam, y los decorados, los robots y la batalla se añadieron después con Blender. El Proyecto Mango ocupó todo 2012 y sirvió para mejorar las herramientas de efectos visuales de Blender, como el seguimiento de cámara y la composición; parte del dinero llegó del Fondo Neerlandés de Cine. La película se publicó en internet el 26 de septiembre de 2012. Derek de Lint interpreta a Thom de mayor, y la fotografía es de Joris Kerbosch. Quédate a los créditos: sus autores piden que ninguna proyección los corte. # Spring {kicker="Sesión 4 · Viernes 27 de noviembre" credits="Escrita y dirigida por Andy Goralczyk · Países Bajos, 2019 · 8 min · Sin diálogos"} ::resource{id="spring"} Una pastora y su perro bajan de las cumbres soleadas a un bosque helado, donde unos espíritus gigantes retienen el cambio de estación. Con su cayado y una campanilla, ella tiene que sacarlos del valle para que el hielo se derrita. Nadie habla; la música de Torin Borrowdale lleva la historia del primer plano al último. Andy Goralczyk, que había sido animador en Elephants Dream trece años antes, escribió y dirigió la película a partir de su infancia en las montañas de Alemania. El equipo empezó con Blender 2.79 y pasó muy pronto a la versión 2.80, todavía en beta, de modo que Spring sirvió de banco de pruebas para esa versión. Se estrenó en YouTube el 4 de abril de 2019, y sus personajes y archivos se publican con licencia CC BY 4.0. # En el cineclub {kicker="Información práctica" credits="Sala 2, Centro Cultural del Puerto · Apertura de puertas a las 20.00"} Las proyecciones empiezan a las 20.30. La entrada es gratuita para los socios y cuesta 3 € para los invitados; la cuota anual es de 15 € y se puede pagar en la puerta. La sala tiene sesenta butacas, y el coloquio posterior a cada película dura una media hora. ## Por qué podemos proyectarlas Cada película se publica con una licencia Creative Commons de reconocimiento, que permite a cualquiera copiar, compartir y proyectar la obra, también en público y cobrando entrada, siempre que se cite a sus autores. La Blender Foundation pide que los créditos finales se mantengan, y los mantenemos. ## Verlas en casa Los códigos de las cartelas abren las películas en los canales de Blender en YouTube. Si tu móvil no lee un código, escribe la dirección corta que va impresa bajo la imagen. En el PDF de este programa, que está en la web del cineclub, cada cartela enlaza con su película. ## Créditos :::paragraphs{style="credits"} **Elephants Dream** © 2006 Blender Foundation e Instituto Neerlandés de Arte Mediático · 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"} Compuesto en Literata, Big Shoulders Display y Barlow Semi Condensed (SIL Open Font License) · Textos y cartelas: originales, CC BY 4.0 · Películas: 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 · core, fonts, viewer, pdf, images: igual en todas las recetas · 310 líneas// ─── 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 ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)

Variantes

#Imprime el fotograma de la propia película

Descarga la miniatura más grande de YouTube en lugar de dibujar una cartela, si la licencia de la película y tu alojamiento lo permiten, y declara el tamaño del fotograma.

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

#Reproduce las películas en la edición HTML

renderToHtml pone en cada vídeo el reproductor de YouTube con privacidad mejorada, y un EPUB muestra la portada enlazada con la película; con videoStyle.html: 'poster' la cartela impresa se ve también en pantalla. Consulta Vídeos y Estilo de vídeo.

#Centra una marca más grande

Una marca en el centro de la cartela se reconoce como un reproductor desde el fondo de la sala; las cartelas dejan sin texto su franja central, así que la marca solo tapa el motivo.

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

Errores frecuentes

Error frecuente

El texto dentro de un SVG <img> no puede usar fuentes web

Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →

Error frecuente

Los mapas de bits se miden en píxeles a la resolución del documento: declara el tamaño de impresión

Un recurso de mapa de bits toma su tamaño del ancho y el alto que declara, en píxeles a la resolución del documento, no del archivo. Declara los píxeles del tamaño de impresión (unos 300 ppp al ancho impreso) para que la figura salga a su tamaño y nítida. Figuras y tablas como recursos →

Error frecuente

Traduce Figura y Tabla con defaultResourceTypes(locale)

El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →

Error frecuente

::resource{id="…"} solo admite comillas dobles

Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible. Figuras justo aquí →

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

El desbordamiento del texto de diseño es 'ellipsis-end' por defecto

Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Aviso de maquetación · videoWithoutPoster

Vídeo sin portada

Por qué. Un recurso de vídeo que usa el texto no tiene `video.poster`, así que el impreso muestra un recuadro oscuro con la proporción del vídeo, su marca de reproducción y su código QR.

Solución. Dale una portada al vídeo: un fotograma suyo o una imagen propia (`{ fileId, format, width, height }`), y registra su mapa de bits como cualquier imagen.

  • El motor limita el código al 45 % del lado corto de la portada y la marca al 40 %. En una portada pequeña, un código de 20 mm se reduce, y sus módulos con él: un código de 20 mm necesita una portada de 45 mm de alto como mínimo.
  • Dos elementos con la misma posición se tapan. Da a la marca y al código esquinas distintas, y dibuja la portada con esas esquinas libres.

Créditos

Texto
Texto original, CC BY 4.0
Imágenes
  • Cartela de Elephants Dream, dibujada en código. La película, © 2006 Blender Foundation e Instituto Neerlandés de Arte Mediático, CC BY 2.5, se enlaza desde la página, no se reproduce · Ignacio Ferro · original
  • Cartela de Big Buck Bunny, dibujada en código. La película, © 2008 Blender Foundation, CC BY 3.0, se enlaza desde la página, no se reproduce · Ignacio Ferro · original
  • Cartela de Tears of Steel, dibujada en código. La película, © 2012 Blender Foundation, CC BY 3.0, se enlaza desde la página, no se reproduce · Ignacio Ferro · original
  • Cartela de Spring, dibujada en código. La película, © 2019 Blender Foundation (Blender Studio), CC BY 4.0, se enlaza desde la página, no se reproduce · Ignacio Ferro · original
Fuentes
Literata (SIL OFL 1.1) · Big Shoulders Display (SIL OFL 1.1) · Barlow Semi Condensed (SIL OFL 1.1)
SandboxPDF