En poques paraules
Un programa de sis pàgines per al cicle de tardor d'un cineclub, amb quatre curtmetratges. Cada pàgina porta una imatge amb un codi que el mòbil escaneja per veure la pel·lícula, i al PDF n'hi ha prou de prémer la imatge.
Què compondràs
El programa de tardor del Cineclub Lumen, un quadernet de 165 × 235 mm sobre paper crema: una coberta amb una banda negre pantalla i el títol del cicle en Big Shoulders Display, i després una pàgina per a cadascuna de quatre pel·lícules obertes de la Blender Foundation. Cada pàgina comença amb un avantítol vermell per a la sessió, el títol en majúscules estretes i una línia de crèdits, i a sota hi va una cartel·la a tota l'amplada de la caixa, 125 mm. A la cantonada superior esquerra hi ha una marca de reproducció vermella, i a la inferior dreta, un codi QR de 20 mm. El soci que es va perdre un divendres apunta el mòbil al codi i la pel·lícula s'obre a YouTube; al PDF, tota la cartel·la és l'enllaç. L'adreça curta del peu hi és per al mòbil que no llegeixi el codi.
Aquesta recepta respon a
- Com poso un vídeo en un llibre imprès perquè el lector el pugui obrir des de la pàgina?
La resposta curta
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' });
Ingredients
- Funcions
- Vídeos com a recursosPortades de vídeo: marca de reproducció i codi QRFigures i taules com a recursosTipus de recurs propisPeus numeratsCites que col·loquen les figuresFigures just aquíEstil dels peusLínies de font i crèditFigura i Taula en la teva llenguaEstils de títolAtributs de títolObertures dissenyadesTextos, filets i caixes als dissenys de pàginaColor del paperPaleta de color semànticaExportació a PDF
- També fa servir
- Capçaleres segons el tipus de pàginaEstils de paràgrafFonts incrustades al PDFCapçaleres per seccióCapítols sense número
- Tipografia
- Literata, Big Shoulders Display, Barlow Semi Condensed (SIL OFL 1.1)
- Recursos
- 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, original)
- 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, original)
- 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, original)
- 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, original)
Elaboració
#1 · Cada pel·lícula és un recurs de vídeo
El codi és a la resposta curta, més amunt. Un recurs de vídeo es col·loca, es retola i es numera com una figura, i l'imprès el mostra com la seva portada amb dos elements a sobre. La marca de reproducció i el codi van a cantonades oposades perquè no es toquin, i la portada, de 70 mm d'alt, deixa lloc per a tots dos. El codi fa 20 mm amb una zona de silenci de dos mòduls: https://youtu.be/ més un identificador d'onze caràcters dona 29 mòduls amb el nivell Q, de manera que cada mòdul fa 0,61 mm, el doble del que necessita la càmera d'un mòbil. Q és el nivell més alt que conserva els 29 mòduls amb aquesta longitud (el motor hi puja des de M pel seu compte); H en necessitaria 33 i deixaria els mòduls en 0,54 mm. El tipus video predefinit imprimiria Vídeo 1.1; reanomenat i comptat amb {n} a tot el document, numera les quatre sessions del cicle de Película 1 a Película 4 en l'edició castellana, i les cites de la coberta imprimeixen les mateixes paraules.
#2 · Escriu l'adreça curta i deixa que el motor la completi
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.' } },
];
L'adreça de cada entrada és la que va impresa sota la cartel·la, youtu.be/TLkA0RELQ1g, que un lector pot teclejar. parseVideoUrl llegeix qualsevol forma d'adreça de YouTube o de Vimeo, i videoWatchUrl n'escriu l'adreça canònica: el codi QR i l'enllaç del PDF guarden tots dos https://youtu.be/<id>. Una errata que no deixi un identificador vàlid fa que parseVideoUrl torni undefined, i l'script s'atura abans de compondre, així que un enllaç trencat no arriba mai a la impremta.
#3 · Dibuixa una cartel·la com a portada
// 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 majoria de projectes la portada és un fotograma de la pel·lícula: el Sandbox la descarrega quan escrius l'adreça, i en codi youtubePosterUrls(id) dona les miniatures de YouTube de la més gran (1280 × 720) a la més petita. Els fotogrames de les plataformes pertanyen a les pel·lícules i a les seves llicències, i un programa imprès pot fer servir cartel·les pròpies, així que cada pel·lícula en porta aquí una dibuixada en un canvas amb el seu color, el seu motiu, el seu títol, el seu any i el seu director. La cartel·la deixa lliures la cantonada superior esquerra i la inferior dreta per a la marca i el codi. És un canvas i no un SVG perquè un SVG dibuixat com a imatge no arriba a les fonts web de la pàgina, i les cartel·les componen el text amb les lletres del quadernet. Cada cartel·la fa 1600 × 900 píxels, el 16:9 de les pel·lícules, i video.poster declara aquesta mida: a 125 mm d'amplada s'imprimeix a 325 ppp. Un vídeo sense portada s'imprimeix com una capsa fosca en 16:9 amb la mateixa marca i el mateix codi, i la composició avisa.
#4 · La portada va sota l'obertura
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·lícula és un títol de primer nivell l'obertura del qual apila l'avantítol de la sessió, el títol i la línia de crèdits, tots tres presos d'atributs del títol. ::resource col·loca la portada just a sota amb position: 'here', de manera que totes les pàgines de pel·lícula segueixen el mateix ordre. La coberta és un estil de títol amb la banda, el títol i les pastilles fets d'elements de disseny; les quatre pastilles prenen de la paleta els colors de les pel·lícules, igual que les cartel·les.
#5 · Una paleta per a les pàgines i les cartel·les
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 vermell i el crema són els del quadernet; els quatre colors de pel·lícula (amb els noms dels projectes de Blender Orange, Peach i Mango, més Spring) pinten les pastilles de la coberta i les cartel·les. La marca de reproducció té el mateix vermell que els avantítols, i la placa del QR és el crema de la pàgina amb els mòduls en tinta: un codi fosc sobre una placa clara, el contrast que esperen tots els lectors de codis.
La recepta completa
// ═══ 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`---Mostra en Markdown · 69 línies · content.es.md
title: "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 a totes les receptes · 310 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Imprimeix el fotograma de la mateixa pel·lícula
Descarrega la miniatura més gran de YouTube en lloc de dibuixar una cartel·la, si la llicència de la pel·lícula i el teu allotjament ho permeten, i declara la mida 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]);
+}#Reprodueix les pel·lícules a l'edició HTML
renderToHtml posa a cada vídeo el reproductor de YouTube amb privadesa millorada, i un EPUB mostra la portada enllaçada amb la pel·lícula; amb videoStyle.html: 'poster' la cartel·la impresa es veu també en pantalla. Consulta Vídeos i Estil de vídeo.
#Centra una marca més gran
Una marca al centre de la cartel·la es reconeix com un reproductor des del fons de la sala; les cartel·les deixen sense text la franja central, així que la marca només tapa el motiu.
- playMark: { shape: 'rounded', position: 'top-left', size: mm(8), inset: mm(4),
+ playMark: { shape: 'rounded', position: 'center', size: mm(14), inset: mm(4),Errors freqüents
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Els mapes de bits es mesuren en píxels a la resolució del document: declara la mida d'impressió
Un recurs de mapa de bits pren la mida de l'amplada i l'alçada que declara, en píxels a la resolució del document, no del fitxer. Declara els píxels de la mida d'impressió (uns 300 ppp a l'amplada impresa) perquè la figura surti a la seva mida i nítida. Figures i taules com a recursos →
Error freqüent
Tradueix Figura i Taula amb defaultResourceTypes(locale)
El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →
Error freqüent
::resource{id="…"} només admet cometes dobles
Una inserció de bloc només es reconeix com a ::resource{id="…"} amb cometes dobles; qualsevol altra forma es queda al text com una línia visible. Figures just aquí →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Avís de maquetació · videoWithoutPoster
Vídeo sense portada
Per què. Un recurs de vídeo que fa servir el text no té `video.poster`, de manera que l'imprès mostra un requadre fosc amb la proporció del vídeo, la marca de reproducció i el codi QR.
Solució. Dona una portada al vídeo: un fotograma seu o una imatge pròpia (`{ fileId, format, width, height }`), i registra'n el mapa de bits com qualsevol imatge.
- El motor limita el codi al 45 % del costat curt de la portada i la marca al 40 %. En una portada petita, un codi de 20 mm es redueix, i els seus mòduls amb ell: un codi de 20 mm necessita una portada de 45 mm d'alt com a mínim.
- Dos elements amb la mateixa posició es tapen. Dona a la marca i al codi cantonades diferents, i dibuixa la portada amb aquestes cantonades lliures.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Imatges
- 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 · original
- 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 · original
- 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 · original
- 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 · original
- Fonts
- Literata (SIL OFL 1.1) · Big Shoulders Display (SIL OFL 1.1) · Barlow Semi Condensed (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


