In breve
Una scheda di fisica con due brevi filmati di un pendolo. Sulla carta ogni filmato è un’immagine fissa con un codice da inquadrare; sullo schermo e nell’e-book i filmati si riproducono sulla pagina.
Che cosa comporrai
La scheda di laboratorio 4.2 di Bench Physics, un quaderno A4 per studenti dai 16 ai 18 anni, sul periodo di un pendolo. Una fascia color ruggine porta il titolo e i campi per nome, gruppo e data. Al piede della prima pagina due clip dello stesso pendolo, lasciato andare da 10° e da 80°, stanno affiancate. Sulla carta ogni clip è il suo primo fotogramma, con un segno di riproduzione e un codice QR che apre l’MP4. Nell’edizione a schermo accanto alle pagine, e nell’EPUB che scrive il pen, le stesse due risorse si riproducono nel player video del lettore. La seconda pagina contiene il procedimento, una tabella da compilare, i periodi esatti calcolati dal codice e quattro domande con i riquadri per le risposte.
Questa ricetta risponde a
- Come riproducono i video del libro l’edizione HTML e l’EPUB, e come nascondo il pulsante di download?
- Come metto un video in un libro stampato in modo che il lettore possa aprirlo dalla pagina?
La risposta breve
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.' }),
];
Ingredienti
- Funzionalità
- Copertine dei video: segno di riproduzione e codice QRVideo come risorseEsportazione in EPUBEdizione di lettura in HTMLUn progetto più semplice per lo schermoTabelle dai datiStile delle tabelleRiquadriRiquadri annidatiSpazio verticale esplicitoAperture progettateAttributi dei titoliTesto, filetti e riquadri nei progetti di paginaTestatine e folioTestatine secondo il ruolo della paginaTipi di risorsa personalizzatiTavolozza di colori semanticaFont incorporati nel PDF
- Usa anche
- Citazioni che collocano le figureFigure proprio quiFigura e Tabella nella tua linguaFascia di capitolo a tutta larghezzaStili di paragrafoEsportazione in PDFFigure e tabelle come risorseApici e pedici
- Tipo
- Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
- Risorse
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)
Preparazione
#1 · Dai a ogni clip un indirizzo di produzione e un file
Il codice è nella risposta breve qui sopra. Una clip tua ha due indirizzi. video.url è dove la troverà il libro pubblicato: il codice QR lo stampa e il PDF vi collega il poster, quindi qui è l’indirizzo jsDelivr della clip, quello che un telefono può aprire oggi. video.fileId nomina il file stesso, tenuto fuori dal documento come una bitmap, per gli output che possono contenerlo o riprodurlo da un’altra posizione (vedi Formato del documento › Video). Le opzioni del player in videoStyle.player valgono per ogni clip: si ripetono in loop, partono senza audio, si caricano per intero (27 e 61 KB) e non offrono download, picture in picture né trasmissione, così le clip restano sulla scheda.
#2 · Impagina l’edizione a schermo dalla stessa sorgente
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’edizione a schermo vive nella configurazione come htmlViewer.overrides, che canvas e PDF ignorano. A 122 dpi ogni misura data in punti risulta circa un quarto più grande che a 96, quindi il testo da 9,4 pt si legge a 16 px nel pannello e didascalie, riquadri e tabelle crescono con esso. L’apertura mantiene il titolo e lascia cadere la fascia e i campi, che appartengono alla carta.
#3 · Riproduci le clip nel pannello
// 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 compone ogni risorsa video come un <video> HTML5 con le opzioni del player come attributi: controls, muted, loop, playsinline, preload="auto", disablepictureinpicture, disableremoteplayback e controlslist="nodownload". Quest’ultimo nasconde solo il pulsante. Chiunque abbia l’indirizzo può comunque salvare il file, quindi consideralo ordine, non protezione. resourceVideoUrl passa al backend un indirizzo riproducibile per ogni fileId, come fa resourceImageUrl per i poster. Qui è la risorsa pubblicata; un sito che tiene i caricamenti altrove restituisce il suo URL, o un URL blob dei byte. Senza, il player ripiega su video.url, e senza nessuno dei due il pannello mostra il poster.
#4 · Metti le clip in un EPUB a layout fisso
const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
const media = new Map(await Promise.all(clips.map(async ({ video }) =>
[video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }])));
// Every face as bytes (gotcha: epub-embeds-given-fonts): the files loadFonts loaded, latin
// for each face and greek for the text face, limited to the Greek block and listed after
// latin, so a reading system tries it first for θ and π.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
const fonts = await Promise.all([...faces.map((face) => [face, 'latin']),
...faces.filter(({ family }) => family === TEXT)
.map((face) => [{ ...face, unicodeRange: 'U+0370-03FF' }, 'greek'])]
.map(async ([face, subset]) => ({ ...face, format: 'woff2', bytes: await bytesOf(
`https://cdn.jsdelivr.net/npm/@fontsource/${fontsourceId(face.family)}@5/files/`
+ `${fontsourceId(face.family)}-${subset}-${face.weight}-${face.style}.woff2`) })));
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' },
});
La scheda è costruita attorno a due clip affiancate e a riquadri vuoti da compilare, quindi il layout fisso la conserva: ogni pagina come stampata, con un player dove c’era il poster. Anche un EPUB fluido riprodurrebbe le clip, una sotto l’altra alla larghezza del testo. resourceBytes risponde video/mp4 per ogni clip e un JPEG per ogni poster, e il writer li impacchetta sotto media/ e images/. L’EPUB conserva solo gli attributi che il suo schema conosce (controls, autoplay, muted, loop, playsinline, preload), quindi il pulsante di download e il resto restano al sistema di lettura. Il file supera EPUBCheck senza errori né avvisi.
#5 · Componi θ e π in canvas, PDF ed EPUB
Source Serif 4 tiene le lettere greche in un file Fontsource a parte, greek, accanto a latin. kitFonts(FONTS) trova le lettere greche nel testo e carica quel file per ogni carattere della famiglia, e fontsourceProvider lo incorpora nel PDF accanto al file latin per ogni carattere che compone θ o π, quindi le due lettere non richiedono codice. L’EPUB incorpora solo i font che gli passa il pen, quindi il passo 4 elenca i file greek del carattere del testo dopo quelli latin, ciascuno limitato al blocco greco dal suo unicodeRange.
#6 · Calcola la tabella nel codice
// 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]);
Il periodo esatto richiede l’integrale ellittico completo K, e la media aritmetico-geometrica lo dà in pochi passi: T = T~0~ / AGM(1, cos ½θ~0~). La tabella stampa tre decimali con la virgola decimale in spagnolo, e le righe di 10° e 80° corrispondono alle clip: 2,010 s e 2,282 s, lo 0,2 % e il 13,8 % più lunghi di T~0~ = 2,006 s. In 8 s sono poco meno di quattro oscillazioni complete per la prima clip e tre e mezza per la seconda.
La ricetta 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.25.0 import { buildDocument, buildDocumentWithFonts, prepareFonts, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides, 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 = 'en'; // @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 }); // a long title takes a second line 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 = () => ({ locale: t({ en: 'en-us', es: 'es' }), // The built-in types, already in the sheet's language, counted 1, 2… through the sheet. 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: [ // 'odd': a sheet opens on a recto, with no blank page forced before the first. { 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`---Esempio Markdown · 86 righe · content.en.md
title: "Bench Physics" subtitle: "A lab workbook for ages 16 to 18" --- # The period of a pendulum {kicker="Unit 4 · Oscillations · Lab sheet 4.2" lead="Two pendulums of the same length, released from 10° and from 80°. Time both on video and find out whether the period depends on the size of the swing."} :::callout{type="aim" title="Aim"} To measure the period of a pendulum 1.00 m long from two video clips, and to find out how far the small-angle formula can be trusted. **You need** the two clips, a phone or a screen to play them, and a calculator. ::: ## The two clips :ref{id="small"} and :ref{id="large"} were rendered from a numerical solution of the pendulum’s equation of motion, θ″ = −(*g*/*L*) sin θ, worked out in steps of one millisecond for a pendulum 1.00 m long. Each clip lasts 8.00 s at 25 frames per second, so one frame is 0.04 s. There is no air resistance in the model, so the swings never die away. The still printed for each clip is its first frame, and the code in its corner opens the clip itself. Scan it with a phone, or play the clips in the screen edition of this workbook. Both clips loop, start with the sound off and have a speed menu: slow them to half speed when you count. ## Background A simple pendulum is a small heavy bob on a light string that does not stretch. Pulled aside and let go, it swings to and fro through its lowest point. The **period**, *T*, is the time it takes to go out and come back to where it started. Galileo noticed that a pendulum keeps the same period as its swings die away, and clockmakers relied on that for three hundred years. For small swings the period depends only on the length *L* and on the gravitational field strength *g*: :::paragraphs{style="formula"} *T*~0~ = 2π (*L*/*g*)^1/2^ ::: With *L* = 1.00 m and *g* = 9.81 m s^−2^, the formula gives *T*~0~ = 2.006 s. Its derivation replaces sin θ by θ in radians, which is within 1 % of the truth only up to about 14°. Past that, the pull back towards the middle grows more slowly than the angle, the bob lingers near the turning points and every swing takes a little longer. With the amplitude θ~0~, the angle the bob is released from, and *K*, the complete elliptic integral of the first kind, the exact period is :::paragraphs{style="formula"} *T* = 4 (*L*/*g*)^1/2^ *K*(sin ½θ~0~) ::: ## Method :::callout{type="method"} 1. Play the first clip from the start. The bob is let go from rest in the first frame. 2. Count the complete swings, out and back to the release point, until the clip ends at 8.00 s. Estimate the last one to a quarter of a swing. 3. Divide 8.00 s by the number of swings to find the period. 4. Repeat for the second clip and fill in the table below. ::: ## Results ::resource{id="data"} The values in :ref{id="periods"} come from the exact formula, worked out to three decimals. Compare your periods with the rows for 10° and 80°. ::resource{id="periods"} ## Questions :::callout{type="sheet"} 1. Which clip has the longer period, and by what percentage? :::callout{type="answer"} Answer :::space{lines=3} ::: 2. The bob in the second clip travels a much longer arc. Why does its period not grow in the same proportion? :::callout{type="answer"} Answer :::space{lines=4} ::: 3. The first correction to the small-angle formula gives *T* = *T*~0~ (1 + θ~0~^2^/16), with θ~0~ in radians. Work it out at 80° and compare it with the table. Is one term enough? :::callout{type="answer"} Answer :::space{lines=4} ::: 4. A long-case clock swings 4° either side of the vertical. Would its maker need the exact formula? :::callout{type="answer"} Answer :::space{lines=3} ::: ::: :::paragraphs{style="colophon"} Bench Physics, lab sheet 4.2 · Set in Source Serif 4, Red Hat Display and Red Hat Mono (SIL Open Font License) · Text and clips: 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 'Source Serif 4': ['400', '400i', '700', '700i'], 'Red Hat Display': ['700', '800'], 'Red Hat Mono': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // θ and π: markdown has Greek letters, so the kit loads Source Serif 4's greek file too // and the PDF provider embeds it beside the latin one (gotcha: latin-subset). await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), ...clips.map(({ video }) => loadImage(video.poster.fileId, asset(video.poster.fileId)))]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'The period of a pendulum', es: 'El periodo de un péndulo' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #region epub: a fixed-layout EPUB that carries both MP4 files and plays them on the page const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()); const media = new Map(await Promise.all(clips.map(async ({ video }) => [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }]))); // Every face as bytes (gotcha: epub-embeds-given-fonts): the files loadFonts loaded, latin // for each face and greek for the text face, limited to the Greek block and listed after // latin, so a reading system tries it first for θ and π. const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); const fonts = await Promise.all([...faces.map((face) => [face, 'latin']), ...faces.filter(({ family }) => family === TEXT) .map((face) => [{ ...face, unicodeRange: 'U+0370-03FF' }, 'greek'])] .map(async ([face, subset]) => ({ ...face, format: 'woff2', bytes: await bytesOf( `https://cdn.jsdelivr.net/npm/@fontsource/${fontsourceId(face.family)}@5/files/` + `${fontsourceId(face.family)}-${subset}-${face.weight}-${face.style}.woff2`) }))); 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: uguale in ogni ricetta · 315 righe
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Fai partire da sola una clip
Il player di un video si sovrappone a videoStyle.player. La riproduzione automatica parte sempre senza audio, come chiedono i browser.
];
+clips[0].video.player = { autoplay: true }; // the 10° clip starts as the pane opens#Mostra a schermo il poster stampato
Con html: 'poster' l’edizione HTML compone il poster con il segno di riproduzione e il codice QR, collegato all’indirizzo di produzione, al posto del player.
const videoStyle = {
+ html: 'poster',#Usa un video di YouTube o Vimeo
source: 'youtube' con l’indirizzo del video incorpora il player di YouTube da youtube-nocookie.com (privacy è attivo per impostazione predefinita). Quel player rispetta meno opzioni: controlli, schermo intero, riproduzione automatica, audio disattivato e loop, e quello di Vimeo aggiunge il menu della velocità e il picture in picture; nessuno dei due offre il download. Un EPUB non può incorporare il player di una pagina web, quindi lì il video è il suo poster collegato al video. Le opzioni e la loro portata sono elencate in Configurazione › Stile dei video.
Errori frequenti
Errore frequente
renderToEpub incorpora solo i font che gli passi
Le varianti caricate dal browser non sono file che postext-epub possa leggere: passa in options.fonts i byte di ogni variante usata dalle pagine (gli stessi file woff2 di Fontsource caricati dalla pagina). Una variante omessa viene segnalata come missingFont, e il sistema di lettura compone quel testo con un carattere suo, che in un layout fisso non entra più nelle righe con cui la pagina è stata composta. Incorpora solo varianti la cui licenza lo permette. Esportazione in EPUB →
Errore frequente
I file di Fontsource coprono le lettere, non i simboli
Il kit incorpora il file latin di Fontsource di un carattere, più il suo file latin-ext per č, ł, † e il suo file greek per α, χ (nelle famiglie che ne hanno uno: Lora e Gelasio no), quando il testo li compone, a schermo e nel PDF. Simboli come →, ≈, ✓ e ★ non sono in nessuno di quei file e spariscono nel PDF: disegnali, componili come matematica o scegli un carattere i cui file li contengano. Font incorporati nel PDF →
Gli MP4 sono H.264. Chrome, Edge, Safari e Firefox li riproducono, e così il Chrome for Testing usato dalla cattura. Un Chromium compilato senza i suoi codec proprietari, l’impostazione predefinita open source, no; codifica le clip in WebM (format: 'webm') se per i tuoi lettori conta.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Immagini
- 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
- Font
- Source Serif 4 (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


