En poques paraules
Un full de pràctiques de física amb dos vídeos curts d'un pèndol. En paper cada vídeo és una imatge fixa amb un codi per escanejar; en pantalla i al llibre electrònic el vídeo es reprodueix a la pàgina.
Què compondràs
La pràctica 4.2 de Física al laboratori, un quadern A4 per a batxillerat, sobre el període d'un pèndol. Una banda de color teula porta el títol i els camps per al nom, el grup i la data. Al peu de la primera pàgina, dos vídeos del mateix pèndol, deixat anar des de 10° i des de 80°, queden l'un al costat de l'altre. En paper, cada vídeo és el seu primer fotograma amb una marca de reproducció i un codi QR que obre el MP4. A l'edició de pantalla que acompanya les pàgines, i a l'EPUB que escriu el codi, aquests mateixos recursos es reprodueixen al reproductor de vídeo del lector. La segona pàgina recull el procediment, una taula per omplir, els períodes exactes que calcula el codi i quatre qüestions amb el seu requadre per a la resposta.
Aquesta recepta respon a
- Com reprodueixen l'edició HTML i l'EPUB els vídeos del llibre, i com amago el botó de baixada?
- 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 clip = (id, file, poster, caption, altText) => ({
id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0, caption: t(caption),
note: t({ en: '8.00 s, 25 frames per second · Postext Cookbook, CC BY 4.0',
es: '8,00 s, 25 fotogramas por segundo · Postext Cookbook, CC BY 4.0' }),
altText: t(altText), placement: { position: 'bottom', span: 'column' },
video: { source: 'file', fileId: file, format: 'mp4', width: 640, height: 360, duration: 8,
url: asset(file), // the production address: printed as the QR code, linked from the PDF
poster: { fileId: poster, format: 'jpeg', width: 640, height: 360 }, // the first frame
},
});
const videoStyle = {
playMark: { shape: 'rounded', position: 'top-left', size: mm(7), inset: mm(3),
background: col('ink'), color: col('paper') },
qr: { position: 'bottom-right', size: mm(19), inset: mm(2.5), // 111 characters: 45 modules
color: col('ink'), background: col('paper'), radius: mm(1) },
// The HTML5 player of a file honours every option. download: false writes
// controlslist="nodownload": it hides the button, and the file stays at its address.
player: { download: false, loop: true, muted: true, preload: 'auto',
pictureInPicture: false, remotePlayback: false },
};
const clips = [
clip('small', 'pendulum-small.mp4', 'pendulum-small-poster.jpg',
{ en: 'Released from rest at 10°.', es: 'Soltado desde el reposo a 10°.' },
{ en: 'A rust bob on a dark string hangs a few degrees off the vertical, under a short bar.',
es: 'Una lenteja roja cuelga de un hilo oscuro a pocos grados de la vertical.' }),
clip('large', 'pendulum-large.mp4', 'pendulum-large-poster.jpg',
{ en: 'Released from rest at 80°.', es: 'Soltado desde el reposo a 80°.' },
{ en: 'The same pendulum pulled out almost level with its pivot, a pale arc below it.',
es: 'El mismo péndulo, casi a la altura de su eje, sobre un arco pálido.' }),
];
Ingredients
- Funcions
- Portades de vídeo: marca de reproducció i codi QRVídeos com a recursosExportació a EPUBEdició de lectura en HTMLUn disseny més senzill per a pantallaTaules a partir de dadesEstil de taulesRequadresRequadres niatsEspai vertical explícitObertures dissenyadesAtributs de títolTextos, filets i caixes als dissenys de pàginaCapçaleres i folisCapçaleres segons el tipus de pàginaTipus de recurs propisPaleta de color semànticaFonts incrustades al PDF
- També fa servir
- Cites que col·loquen les figuresFigures just aquíFigura i Taula en la teva llenguaBanda de capítol d'amplada completaEstils de paràgrafExportació a PDFFigures i taules com a recursosSuperíndexs i subíndexs
- Tipografia
- Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
- Recursos
pendulum-large-poster.jpgpendulum-large.mp4pendulum-small-poster.jpgpendulum-small.mp4- Video 1: a pendulum 1 m long released from 10°, 8 s at 25 frames per second, rendered from an RK4 solution of the pendulum equation (1 ms steps) with Python and encoded with ffmpeg (Postext Cookbook, CC BY 4.0)
- Video 2: the same pendulum released from 80°, rendered and encoded the same way (Postext Cookbook, CC BY 4.0)
- The first frame of Video 1, its printed poster (Postext Cookbook, CC BY 4.0)
- The first frame of Video 2, its printed poster (Postext Cookbook, CC BY 4.0)
Elaboració
#1 · Dona a cada vídeo una adreça de producció i un fitxer
El codi és la resposta curta de dalt. Un vídeo propi té dues adreces. video.url és on el trobarà el llibre publicat: el codi QR la imprimeix i el PDF hi enllaça la portada, així que aquí és l'adreça del vídeo a jsDelivr, la que un mòbil pot obrir avui. video.fileId anomena el fitxer, que es desa a part com un mapa de bits, per a les sortides que el poden portar o reproduir des d'un altre lloc (vegeu Format del document › Vídeos). Les opcions de videoStyle.player valen per a tots els vídeos: es repeteixen en bucle, comencen sense so, es carreguen sencers (27 i 61 KB) i no ofereixen baixada, imatge dins d'imatge ni enviament a una altra pantalla, de manera que el vídeo es queda al full.
#2 · Compon l'edició de pantalla amb el mateix original
const [PANE_W, PANE_H] = [400, 640]; // px
const screen = {
page: { width: px(PANE_W), height: px(PANE_H), dpi: 122, // so the 9.4 pt text is 16 px
margins: { top: px(28), bottom: px(28), left: px(22), right: px(22), mirror: false } },
layout: { layoutType: 'single' }, bodyText: { textAlign: 'left' }, // ragged in a narrow pane
headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
fontSize: px(28), lineHeight: px(32), fontWeight: 800, marginBottom: px(16),
advancedDesign: { enabled: false } }] }, // the title alone: the band and fields are print's
header: { elements: [] }, footer: { elements: [] } };
L'edició de pantalla viu a la configuració, a htmlViewer.overrides, que el llenç i el PDF no llegeixen. A 122 ppp cada mida en punts surt una quarta part més gran que a 96, així que el text de 9,4 pt es llegeix a 16 px al plafó, i els peus, els requadres i les taules creixen amb ell. L'obertura conserva el títol i deixa la banda i els camps, que són cosa del paper.
#3 · Reprodueix els vídeos al plafó
// A shadow root keeps the page's styles out, so the pane shows the lines as they were measured.
const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' });
pane.innerHTML = renderToHtml(buildDocument({ markdown, resources },
applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff',
resourceImageUrl: imageUrl, // the posters, until a clip plays
resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here
const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player
pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48;
renderToHtml compon cada recurs de vídeo com un <video> d'HTML5 amb les opcions del reproductor com a atributs: controls, muted, loop, playsinline, preload="auto", disablepictureinpicture, disableremoteplayback i controlslist="nodownload". Aquest últim només amaga el botó. Qui tingui l'adreça pot desar el fitxer igualment, així que és ordre a la interfície, no protecció. resourceVideoUrl dona al motor una adreça reproduïble per a cada fileId, igual que resourceImageUrl fa amb les portades. Aquí és el recurs publicat; un lloc que desi els fitxers en una altra banda torna la seva pròpia URL, o la URL d'un blob amb els bytes. Sense ella, el reproductor fa servir video.url, i sense cap de les dues el plafó mostra la portada.
#4 · Posa els vídeos en un EPUB de maquetació fixa
const media = new Map(await Promise.all(clips.map(async ({ video }) =>
[video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }])));
const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts)
...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) =>
({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))),
...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first
].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) })));
const epub = await renderToEpub([doc], { layout: 'fixed', fonts,
metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG,
rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run
// The clips as video/mp4 and the posters as pictures; the book packs both under media/.
resourceBytes: (fileId) => media.get(fileId)
?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' },
});
El full s'organitza al voltant de dos vídeos l'un al costat de l'altre i de requadres buits per escriure, així que la maquetació fixa el conserva: cada pàgina com s'imprimeix, amb un reproductor on hi havia la portada. Un EPUB fluid també reproduiria els vídeos, l'un sota l'altre i a l'amplada del text. resourceBytes respon video/mp4 per a cada vídeo i un JPEG per a cada portada, i l'escriptor els desa a media/ i a images/. L'EPUB només conserva els atributs que admet el seu esquema (controls, autoplay, muted, loop, playsinline, preload), de manera que el botó de baixada i la resta queden en mans del sistema de lectura. El fitxer passa EPUBCheck sense errors ni avisos.
#5 · Compon θ i π al llenç, al PDF i a l'EPUB
// Fontsource cuts each face by script, and the PDF keeps its latin file (gotcha: latin-subset).
const GREEK = 'U+0370-03FF'; // Greek and Coptic
const file = (family, subset, [weight, style]) => 'https://cdn.jsdelivr.net/npm/@fontsource/'
+ `${fontsourceId(family)}@5/files/${fontsourceId(family)}-${subset}-${weight}-${style}.woff2`;
const faceOf = (spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'];
const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
const greek = FONTS[TEXT].map(faceOf).map(([weight, style]) => ({ family: TEXT, weight, style,
url: file(TEXT, 'greek', [weight, style]) }));
const loadGreek = () => Promise.all(greek.map(async ({ weight, style, url }) => document.fonts.add(
await new FontFace(TEXT, `url(${url})`, { weight: `${weight}`, style, unicodeRange: GREEK })
.load())));
// The PDF takes both files of a text face; it asks for every weight, and Source Serif 4 ships
// 200 to 900 (gotcha: pdf-provider-all-styles). A character comes from the first file with it.
const pdfFont = async (family, weight, style) => {
const latin = await fontsourceProvider(family, weight, style);
return family !== TEXT ? latin : [latin, await decompressWoff2(await bytesOf(file(TEXT,
'greek', [Math.max(weight, 200), style])))];
};
El kit carrega el fitxer latin de Fontsource de cada font, que no porta grec. El codi hi afegeix els fitxers greek de Source Serif 4 com a fonts limitades al bloc grec, passa al PDF els dos fitxers de cada font del text (cada caràcter surt del primer que el té) i dona a l'EPUB els fitxers grecs després dels llatins, cadascun amb el seu unicodeRange. La captura encara avisa que θ i π queden fora del subconjunt llatí; el PDF els porta.
#6 · Calcula la taula al codi
// The arithmetic-geometric mean gives the elliptic integral K of the exact period in a few steps.
const T0 = 2 * Math.PI * Math.sqrt(1 / 9.81); // s: L = 1 m, g = 9.81 m/s²
const agm = (a, b) => (Math.abs(a - b) < 1e-15 ? a : agm((a + b) / 2, Math.sqrt(a * b)));
const period = (degrees) => T0 / agm(1, Math.cos((degrees * Math.PI) / 360));
const num = (x, digits) => x.toFixed(digits).replace('.', t({ en: '.', es: ',' }));
const cell = (content, align = 'right') => ({ content, align, verticalAlign: 'middle' });
const table = (id, caption, head, rows, columnWidths) => ({ id, typeId: 'table', kind: 'table',
createdAt: 0, updatedAt: 0, caption: t(caption), placement: { position: 'here' },
table: { model: { headerRowCount: 1, columnWidths, rows: [head.map((h, i) =>
({ ...cell(h, i ? 'right' : 'left'), isHeader: true })), ...rows] } } });
const periods = table('periods', { en: 'Exact periods of a pendulum 1.00 m long.',
es: 'Periodos exactos de un péndulo de 1,00 m.' },
['Amplitude|Amplitud', 'T (s)', 'T/T~0~', 'Longer by|Aumento'].map((h) =>
t({ en: h.split('|')[0], es: h.split('|').at(-1) })),
[5, 10, 20, 30, 45, 60, 80, 90].map((d) => [cell(`${d}°`, 'left'), cell(num(period(d), 3)),
cell(num(period(d) / T0, 3)), cell(`${num((period(d) / T0 - 1) * 100, 2)} %`)]),
[1.2, 1, 1, 1.1]);
const blank = cell(' \n '); // two empty lines: room to write
const data = table('data', { en: 'Your measurements.', es: 'Tus medidas.' },
t({ en: ['Clip', 'Swings in 8 s', 'T (s)'], es: ['Vídeo', 'Oscilaciones en 8 s', 'T (s)'] }),
[10, 80].map((d, i) => [cell(`${t({ en: 'Video', es: 'Vídeo' })} ${i + 1} · ${d}°`, 'left'),
blank, blank]), [1.2, 1.5, 0.8]);
El període exacte necessita la integral el·líptica completa K, i la mitjana aritmeticogeomètrica la dona en uns quants passos: T = T~0~ / AGM(1, cos ½θ~0~). La taula imprimeix tres decimals amb coma decimal en castellà, i les files de 10° i 80° coincideixen amb els vídeos: 2,010 s i 2,282 s, un 0,2 % i un 13,8 % més que T~0~ = 2,006 s. En 8 s surten gairebé quatre oscil·lacions completes al primer vídeo i tres i mitja al segon.
La recepta completa
// ═══ Postext Cookbook · Nº 132 · A lab sheet whose clips play on screen and in the EPUB ════ // https://postext.dev/en/cookbook/pendulum-lab-video-players // Code: MIT · Text: original (CC BY 4.0) · Clips: rendered in code (CC BY 4.0) // Fonts: Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1) · Needs postext ≥ 1.16.1 import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides, clearMeasurementCache, registerResourceImage, defaultResourceTypes, resourceVideoLink, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; import { renderToEpub, readEpub } from 'https://esm.sh/postext-epub'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'pendulum-lab-video-players'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // the clips' own colours: a cream ground, the rust bob, warm black ink ink: '#22201b', // text, the play mark, the QR modules, table heads rust: '#a8432b', // the accent: the band, labels, list numbers (the bob's red, darker) cream: '#f6f1e4', rule: '#cbc2ae', // box fills, a shade deeper than the clips; hairlines muted: '#68625a', paper: '#ffffff' }; // muted: running heads, notes, prompts const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.rust }) // defaults: rust .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, MONO] = ['Source Serif 4', 'Red Hat Display', 'Red Hat Mono']; const [TOP, INNER, OUTER, BAND, LEAD] = [22, 20, 17, 84, 13.2]; // mm, A4; LEAD in pt const px = (value) => ({ value, unit: 'px' }); // the screen edition's sizes const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x, y }, size }); const label = (size, color = 'rust') => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: 600, letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(color) }); // #region answer: two clips of the book's own: a poster and a QR code, or a player const clip = (id, file, poster, caption, altText) => ({ id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0, caption: t(caption), note: t({ en: '8.00 s, 25 frames per second · Postext Cookbook, CC BY 4.0', es: '8,00 s, 25 fotogramas por segundo · Postext Cookbook, CC BY 4.0' }), altText: t(altText), placement: { position: 'bottom', span: 'column' }, video: { source: 'file', fileId: file, format: 'mp4', width: 640, height: 360, duration: 8, url: asset(file), // the production address: printed as the QR code, linked from the PDF poster: { fileId: poster, format: 'jpeg', width: 640, height: 360 }, // the first frame }, }); const videoStyle = { playMark: { shape: 'rounded', position: 'top-left', size: mm(7), inset: mm(3), background: col('ink'), color: col('paper') }, qr: { position: 'bottom-right', size: mm(19), inset: mm(2.5), // 111 characters: 45 modules color: col('ink'), background: col('paper'), radius: mm(1) }, // The HTML5 player of a file honours every option. download: false writes // controlslist="nodownload": it hides the button, and the file stays at its address. player: { download: false, loop: true, muted: true, preload: 'auto', pictureInPicture: false, remotePlayback: false }, }; const clips = [ clip('small', 'pendulum-small.mp4', 'pendulum-small-poster.jpg', { en: 'Released from rest at 10°.', es: 'Soltado desde el reposo a 10°.' }, { en: 'A rust bob on a dark string hangs a few degrees off the vertical, under a short bar.', es: 'Una lenteja roja cuelga de un hilo oscuro a pocos grados de la vertical.' }), clip('large', 'pendulum-large.mp4', 'pendulum-large-poster.jpg', { en: 'Released from rest at 80°.', es: 'Soltado desde el reposo a 80°.' }, { en: 'The same pendulum pulled out almost level with its pivot, a pale arc below it.', es: 'El mismo péndulo, casi a la altura de su eje, sobre un arco pálido.' }), ]; // #endregion // #region periods: Table 2 is worked out here, not typed: T = T0 / AGM(1, cos ½θ0) // The arithmetic-geometric mean gives the elliptic integral K of the exact period in a few steps. const T0 = 2 * Math.PI * Math.sqrt(1 / 9.81); // s: L = 1 m, g = 9.81 m/s² const agm = (a, b) => (Math.abs(a - b) < 1e-15 ? a : agm((a + b) / 2, Math.sqrt(a * b))); const period = (degrees) => T0 / agm(1, Math.cos((degrees * Math.PI) / 360)); const num = (x, digits) => x.toFixed(digits).replace('.', t({ en: '.', es: ',' })); const cell = (content, align = 'right') => ({ content, align, verticalAlign: 'middle' }); const table = (id, caption, head, rows, columnWidths) => ({ id, typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, caption: t(caption), placement: { position: 'here' }, table: { model: { headerRowCount: 1, columnWidths, rows: [head.map((h, i) => ({ ...cell(h, i ? 'right' : 'left'), isHeader: true })), ...rows] } } }); const periods = table('periods', { en: 'Exact periods of a pendulum 1.00 m long.', es: 'Periodos exactos de un péndulo de 1,00 m.' }, ['Amplitude|Amplitud', 'T (s)', 'T/T~0~', 'Longer by|Aumento'].map((h) => t({ en: h.split('|')[0], es: h.split('|').at(-1) })), [5, 10, 20, 30, 45, 60, 80, 90].map((d) => [cell(`${d}°`, 'left'), cell(num(period(d), 3)), cell(num(period(d) / T0, 3)), cell(`${num((period(d) / T0 - 1) * 100, 2)} %`)]), [1.2, 1, 1, 1.1]); const blank = cell(' \n '); // two empty lines: room to write const data = table('data', { en: 'Your measurements.', es: 'Tus medidas.' }, t({ en: ['Clip', 'Swings in 8 s', 'T (s)'], es: ['Vídeo', 'Oscilaciones en 8 s', 'T (s)'] }), [10, 80].map((d, i) => [cell(`${t({ en: 'Video', es: 'Vídeo' })} ${i + 1} · ${d}°`, 'left'), blank, blank]), [1.2, 1.5, 0.8]); // #endregion // #region opener: a rust band with the sheet's number, its title and the fields to fill in const words = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left', overflow: 'wrap', placement, ...extra }); // titles wrap (gotcha: overflow-ellipsis-default) const names = t({ en: ['Name', 'Group', 'Date'], es: ['Nombre', 'Grupo', 'Fecha'] }); const fields = [[0, 86], [90, 30], [124, 49]].flatMap(([x, width], i) => [ // mm, from the margin words(`label-${i}`, names[i], MONO, 7, at('page', 'top-left', mm(INNER + x), mm(BAND - 16)), label(7, 'paper')), { kind: 'box', id: `field-${i}`, style: { backgroundColor: col('paper'), borderRadius: mm(1) }, placement: at('page', 'top-left', mm(INNER + x), mm(BAND - 12.5), { width: mm(width), height: mm(6.5) }) }]); const opener = { enabled: true, minHeight: mm(BAND - TOP + 6), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('rust') }, placement: at('bleed', 'top-left', mm(0), mm(0), { width: 'fill', height: mm(BAND) }) }, words('kicker', '{attr.kicker}', MONO, 8.5, at('page', 'top-left', mm(INNER), mm(14)), label(8.5, 'paper')), words('title', '{titleText}', DISPLAY, 46, at('#kicker', 'below', mm(0), mm(2.5), { width: mm(150) }), { fontWeight: 800, lineHeight: 0.98 }), words('lead', '{attr.lead}', TEXT, 10, at('#title', 'below', mm(0), mm(3.5), { width: mm(158) }), { lineHeight: 1.4 }), ...fields] } }; // a label over a white field for each, to fill in by hand // #endregion const head = (id, content, edge, x, extra) => ({ kind: 'text', id, content, parity: 'even', pages: 'body', ...label(7, 'muted'), fontWeight: 500, placement: at('page', edge, mm(x), mm(12)), ...extra }); const header = { elements: [ head('folio', '{pageNumber}', 'top-left', OUTER, { color: col('rust'), fontWeight: 700 }), head('title', '{title} · {attr.kicker}', 'top-left', OUTER + 7)] }; const footer = { elements: [head('drop-folio', '{title} · {pageNumber}', 'bottom-right', 0, { parity: 'all', pages: 'opener', placement: at('page', 'bottom-right', mm(-OUTER), mm(-11)) })] }; const box = (id, extra) => ({ id, background: col('cream'), borderRadius: mm(1.5), padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) }, titleStyle: { ...label(7.5), gap: mm(1.2) }, body: { fontSize: pt(9.2), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) }, ...extra }); const config = () => ({ // a factory: the engine caches configs by identity locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) // Figure, Table, Video in the sheet's language, counted 1, 2… (gotcha: resource-types-locale) resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never', shortLabel: t({ en: type.name, es: type.name.toLowerCase() }) })), colorPalette, videoStyle, page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(7) }, // columns of 83 mm bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rust'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 700, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ] }, orderedLists: { numberFormat: 'arabic', fontFamily: MONO, fontWeight: 700, color: col('rust'), marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [ box('aim', { stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rust') } }), box('method'), box('sheet'), { id: 'answer', background: col('paper'), borderRadius: mm(1), marginTop: mm(1.2), marginBottom: mm(2.5), border: { enabled: true, color: col('rule'), width: pt(0.6) }, padding: { top: mm(1), right: mm(2.5), bottom: mm(1), left: mm(2.5) }, body: { ...label(6.5, 'muted'), fontWeight: 500, lineHeight: pt(LEAD) } }, ], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: MONO, headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(8.5), bodyColor: col('ink'), cellPadding: mm(1.4) }, captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), labelBold: true, labelColor: col('rust'), gap: mm(2), note: { fontSize: pt(7), color: col('muted') } }, paragraphStyles: [ { id: 'formula', fontSize: pt(11), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }, { id: 'colophon', fontFamily: MONO, fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], header, footer, htmlViewer: { overrides: screen }, // canvas and PDF ignore the overrides }); // #region screen: the screen edition: one column at the pane's width, the clips as players const [PANE_W, PANE_H] = [400, 640]; // px const screen = { page: { width: px(PANE_W), height: px(PANE_H), dpi: 122, // so the 9.4 pt text is 16 px margins: { top: px(28), bottom: px(28), left: px(22), right: px(22), mirror: false } }, layout: { layoutType: 'single' }, bodyText: { textAlign: 'left' }, // ragged in a narrow pane headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false }, fontSize: px(28), lineHeight: px(32), fontWeight: 800, marginBottom: px(16), advancedDesign: { enabled: false } }] }, // the title alone: the band and fields are print's header: { elements: [] }, footer: { elements: [] } }; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 86 línies · content.es.md
title: "Física en el laboratorio" subtitle: "Cuaderno de prácticas, de 16 a 18 años" --- # El periodo de un péndulo {kicker="Unidad 4 · Oscilaciones · Práctica 4.2" lead="Dos péndulos de la misma longitud, soltados desde 10° y desde 80°. Cronometra los dos en vídeo y averigua si el periodo depende de la amplitud de la oscilación."} :::callout{type="aim" title="Objetivo"} Medir en vídeo el periodo de un péndulo de 1,00 m y ver hasta dónde vale la fórmula de las oscilaciones pequeñas. **Necesitas** los vídeos, un móvil o una pantalla y una calculadora. ::: ## Los dos vídeos El :ref{id="small"} y el :ref{id="large"} se generaron a partir de una solución numérica de la ecuación del movimiento del péndulo, θ″ = −(*g*/*L*) sen θ, calculada en pasos de un milisegundo para un péndulo de 1,00 m. Cada uno dura 8,00 s a 25 fotogramas por segundo, así que un fotograma son 0,04 s. El modelo no tiene rozamiento, así que las oscilaciones no se apagan. La imagen impresa de cada vídeo es su primer fotograma, y el código de la esquina lo abre. Escanéalo con el móvil o mira los vídeos en la edición de pantalla. Los dos se repiten en bucle, empiezan sin sonido y tienen un menú de velocidad: bájala a la mitad para contar. ## Fundamento Un péndulo simple es una lenteja pequeña y pesada colgada de un hilo ligero que no se estira. Si se aparta y se suelta, oscila a uno y otro lado de su punto más bajo. El **periodo**, *T*, es el tiempo que tarda en ir y volver al punto de partida. Galileo observó que un péndulo conserva el periodo mientras sus oscilaciones se apagan, y los relojeros se apoyaron en ello durante tres siglos. En oscilaciones pequeñas, el periodo solo depende de la longitud *L* y de la intensidad del campo gravitatorio *g*: :::paragraphs{style="formula"} *T*~0~ = 2π (*L*/*g*)^1/2^ ::: Con *L* = 1,00 m y *g* = 9,81 m s^−2^, *T*~0~ vale 2,006 s. Para deducirla se sustituye sen θ por θ en radianes, lo que solo vale con un error menor del 1 % hasta unos 14°. Más allá, la fuerza que devuelve la lenteja al centro crece más despacio que el ángulo, la lenteja se entretiene cerca de los extremos y cada oscilación tarda algo más. Con la amplitud θ~0~, el ángulo desde el que se suelta la lenteja, y *K*, la integral elíptica completa de primera especie, el periodo exacto es :::paragraphs{style="formula"} *T* = 4 (*L*/*g*)^1/2^ *K*(sen ½θ~0~) ::: ## Procedimiento :::callout{type="method"} 1. Reproduce el primer vídeo desde el principio. La lenteja se suelta desde el reposo en el primer fotograma. 2. Cuenta las oscilaciones completas, de ida y vuelta al punto de partida, hasta que el vídeo acaba a los 8,00 s. Estima la última con una precisión de un cuarto de oscilación. 3. Divide 8,00 s entre el número de oscilaciones para obtener el periodo. 4. Repite con el segundo vídeo y completa la tabla. ::: ## Resultados ::resource{id="data"} Los valores de la :ref{id="periods"} salen de la fórmula exacta, con tres decimales. Compara tus periodos con las filas de 10° y de 80°. ::resource{id="periods"} ## Cuestiones :::callout{type="sheet"} 1. ¿Qué vídeo tiene el periodo más largo, y en qué porcentaje? :::callout{type="answer"} Respuesta :::space{lines=3} ::: 2. La lenteja del segundo vídeo recorre un arco mucho más largo. ¿Por qué su periodo no crece en la misma proporción? :::callout{type="answer"} Respuesta :::space{lines=4} ::: 3. La primera corrección a la fórmula de las oscilaciones pequeñas da *T* = *T*~0~ (1 + θ~0~^2^/16), con θ~0~ en radianes. Calcúlala para 80° y compárala con la tabla. ¿Basta con un término? :::callout{type="answer"} Respuesta :::space{lines=4} ::: 4. Un reloj de pie oscila 4° a cada lado de la vertical. ¿Necesitaría su relojero la fórmula exacta? :::callout{type="answer"} Respuesta :::space{lines=3} ::: ::: :::paragraphs{style="colophon"} Física en el laboratorio, práctica 4.2 · Compuesto en Source Serif 4, Red Hat Display y Red Hat Mono (SIL Open Font License) · Texto y vídeos: Postext Cookbook, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook const resources = [...clips, data, periods]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Source Serif 4': ['400', '400i', '700', '700i'], 'Red Hat Display': ['700', '800'], 'Red Hat Mono': ['400', '500', '600', '700'] }; // #region greek: θ and π come from the text face's greek file, which the kit does not load // Fontsource cuts each face by script, and the PDF keeps its latin file (gotcha: latin-subset). const GREEK = 'U+0370-03FF'; // Greek and Coptic const file = (family, subset, [weight, style]) => 'https://cdn.jsdelivr.net/npm/@fontsource/' + `${fontsourceId(family)}@5/files/${fontsourceId(family)}-${subset}-${weight}-${style}.woff2`; const faceOf = (spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']; const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()); const greek = FONTS[TEXT].map(faceOf).map(([weight, style]) => ({ family: TEXT, weight, style, url: file(TEXT, 'greek', [weight, style]) })); const loadGreek = () => Promise.all(greek.map(async ({ weight, style, url }) => document.fonts.add( await new FontFace(TEXT, `url(${url})`, { weight: `${weight}`, style, unicodeRange: GREEK }) .load()))); // The PDF takes both files of a text face; it asks for every weight, and Source Serif 4 ships // 200 to 900 (gotcha: pdf-provider-all-styles). A character comes from the first file with it. const pdfFont = async (family, weight, style) => { const latin = await fontsourceProvider(family, weight, style); return family !== TEXT ? latin : [latin, await decompressWoff2(await bytesOf(file(TEXT, 'greek', [Math.max(weight, 200), style])))]; }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadGreek(), ...clips.map(({ video }) => loadImage(video.poster.fileId, asset(video.poster.fileId)))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'The period of a pendulum', es: 'El periodo de un péndulo' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: pdfFont, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #region epub: a fixed-layout EPUB that carries both MP4 files and plays them on the page const media = new Map(await Promise.all(clips.map(async ({ video }) => [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }]))); const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts) ...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) => ({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))), ...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first ].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) }))); const epub = await renderToEpub([doc], { layout: 'fixed', fonts, metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG, rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run // The clips as video/mp4 and the posters as pictures; the book packs both under media/. resourceBytes: (fileId) => media.get(fileId) ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' }, }); // #endregion const [read, kb] = [readEpub(epub), (bytes) => `${Math.round(bytes.length / 1024)} KB`]; const list = (lines) => `<ul>${lines.map((line) => `<li>${line}</li>`).join('')}</ul>`; const desk = Object.assign(document.createElement('section'), { id: 'editions', innerHTML: ` <style>#editions{display:flex;flex-wrap:wrap;gap:28px;justify-content:center;align-items:start; padding:28px 16px 0;color:#d9d5cc}#editions figure{margin:0}#editions figcaption{margin-top:16px; color:#8b8f97}#screen{width:${PANE_W}px;height:${PANE_H}px;overflow:auto;border-radius:12px; box-shadow:0 0 0 8px #1d1f24}#editions article{width:min(380px,92vw);padding:4px 18px; border-radius:8px;background:#15181d;overflow-wrap:anywhere}#editions a{color:#d8a21a}</style> <figure><div id="screen" role="region" tabindex="0"></div><figcaption>${t({ en: 'Screen' + ' edition', es: 'Edición de pantalla' })} · HTML · ${PANE_W} × ${PANE_H} px</figcaption> </figure><article><h3>EPUB 3 · ${read.layout}</h3> <p>${read.spine.length} pages · ${kb(epub)}. The book carries:</p>${list([...read.manifest .values()].filter((item) => item.mediaType === 'video/mp4').map(({ path }) => `${path.slice(read.root.length)} · ${kb(read.files.get(path))}`))} <p>The QR codes and the PDF links open:</p>${list(clips.map(resourceVideoLink))} <a download="${RECIPE}.epub" href="${URL.createObjectURL(new Blob([epub], { type: 'application/epub+zip' }))}">Download ${RECIPE}.epub</a></article>` }); document.getElementById('pages').before(desk); // #region pane: the screen edition as HTML, each clip in the browser's own player // A shadow root keeps the page's styles out, so the pane shows the lines as they were measured. const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' }); pane.innerHTML = renderToHtml(buildDocument({ markdown, resources }, applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff', resourceImageUrl: imageUrl, // the posters, until a clip plays resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48; // #endregionKit · 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
#Fes que un vídeo comenci tot sol
El player de cada vídeo se superposa a videoStyle.player. La reproducció automàtica sempre comença sense so, com exigeixen els navegadors.
];
+clips[0].video.player = { autoplay: true }; // the 10° clip starts as the pane opens#Mostra la portada impresa en pantalla
Amb html: 'poster', l'edició HTML compon la portada amb la seva marca de reproducció i el seu codi QR, enllaçada a l'adreça de producció, en lloc del reproductor.
const videoStyle = {
+ html: 'poster',#Fes servir un vídeo de YouTube o de Vimeo
source: 'youtube' amb l'adreça del vídeo insereix el reproductor de YouTube des de youtube-nocookie.com (privacy està activat per defecte). Aquest reproductor atén menys opcions: controls, pantalla completa, reproducció automàtica, silenci i bucle; el de Vimeo hi afegeix el menú de velocitat i la imatge dins d'imatge, i cap dels dos no ofereix baixada. Un EPUB no pot inserir el reproductor d'una pàgina web, així que allà el vídeo és la seva portada enllaçada. Les opcions i el seu abast són a Configuració › Estil de vídeo.
Errors freqüents
Error freqüent
renderToEpub només incrusta les fonts que li passes
Les fonts que ha carregat el navegador no són fitxers que postext-epub pugui llegir: passa a options.fonts els bytes de cada font que fan servir les pàgines (els mateixos woff2 de Fontsource que va carregar la pàgina). Una font que falti s'avisa com a missingFont, i el sistema de lectura compon aquest text amb una de seva, que en una maquetació fixa ja no cap a les línies amb què es va compondre la pàgina. Incrusta només fonts la llicència de les quals ho permeti. Exportació a EPUB →
Error freqüent
Els fitxers latin de Fontsource només porten glifs de l'interval llatí
El proveïdor del PDF incrusta els fitxers latin de Fontsource, que cobreixen el castellà i les llengües de l'Europa occidental però no →, ≈, ✓, ★, el grec ni les lletres de l'Europa central; aquests glifs falten al PDF. Mantén el text del PDF dins de l'interval latin. Fonts incrustades al PDF →
Error freqüent
El PDF demana tots els gruixos i estils de cada família
renderToPdf demana al proveïdor de fonts la negreta, la cursiva i la negreta cursiva de cada família que un bloc podria fer servir, encara que no s'imprimeixi mai, i un sol rebuig atura l'exportació. El proveïdor s'ha d'ajustar al gruix més proper que tingui la família i tornar a la rodona quan no hi hagi cursiva. Fonts incrustades al PDF →
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
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 →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
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
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
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 →
Els MP4 són H.264. Chrome, Edge, Safari i Firefox els reprodueixen, i també el Chrome for Testing de la captura. Un Chromium compilat sense els seus còdecs propietaris, que és el que fa per defecte el projecte obert, no els reprodueix; codifica els vídeos en WebM (format: 'webm') si això importa als teus lectors.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Imatges
- Video 1: a pendulum 1 m long released from 10°, 8 s at 25 frames per second, rendered from an RK4 solution of the pendulum equation (1 ms steps) with Python and encoded with ffmpeg · Postext Cookbook · CC BY 4.0
- Video 2: the same pendulum released from 80°, rendered and encoded the same way · Postext Cookbook · CC BY 4.0
- The first frame of Video 1, its printed poster · Postext Cookbook · CC BY 4.0
- The first frame of Video 2, its printed poster · Postext Cookbook · CC BY 4.0
- Fonts
- Source Serif 4 (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (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)


