Aller au contenu principal
Recette numéro 132

Recettes · Chapitre 10 · Sortie et intégration

Une fiche de TP aux vidéos lues à l’écran et dans l’EPUB

Deux ressources vidéo avec leurs fichiers MP4 : l’imprimé montre la première image et un QR code, renderToHtml et postext-epub lisent les fichiers.

  • Exemple en anglais : pas encore d’édition française
  • Format 210 × 297 mm
  • 2 colonnes, Gouttière de 7 mm
  • Source Serif 4 9,4/13,2
  • Red Hat Display
  • Red Hat Mono
  • 2 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 194 ms
  • 239 lignes de code

En bref

Une fiche de travaux pratiques de physique avec deux courts films d’un pendule. Sur papier, chaque film est une image fixe avec un code à scanner ; à l’écran et dans le livre numérique, les films se lisent sur la page.

Ce que vous allez composer

La fiche de TP 4.2 de Bench Physics, un cahier A4 pour les élèves de 16 à 18 ans, sur la période d’un pendule. Un bandeau rouille porte le titre et les champs du nom, du groupe et de la date. Au pied de la première page, deux vidéos du même pendule, lâché à 10° et à 80°, sont côte à côte. Sur papier, chaque vidéo est sa première image, avec un symbole de lecture et un QR code qui ouvre le MP4. Dans l’édition écran à côté des pages, et dans l’EPUB qu’écrit le pen, les deux mêmes ressources se lisent dans le lecteur vidéo de chacun. La deuxième page contient le protocole, un tableau à remplir, les périodes exactes calculées par le code et quatre questions avec des cases pour les réponses.

Cette recette répond à

  • Comment l’édition HTML et l’EPUB lisent-elles les vidéos du livre, et comment masquer le bouton de téléchargement ?
  • Comment mettre une vidéo dans un livre imprimé pour qu’un lecteur puisse l’ouvrir depuis la page ?

La réponse courte

script.js · lignes 32–61dans le code complet
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.' }),
];

Ingrédients

Typographie
Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
Ressources
  • pendulum-large-poster.jpg
  • pendulum-large.mp4
  • pendulum-small-poster.jpg
  • pendulum-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)

Préparation

#1 · Donner à chaque vidéo une adresse de production et un fichier

Le code est la réponse courte ci-dessus. Une vidéo à vous a deux adresses. video.url est l’endroit où le livre publié la trouvera : le QR code l’imprime et le PDF y relie la vignette, c’est donc ici l’adresse jsDelivr de la vidéo, celle qu’un téléphone peut ouvrir dès aujourd’hui. video.fileId désigne le fichier lui-même, tenu à part comme une image matricielle, pour les sorties qui peuvent l’embarquer ou le lire depuis un autre endroit (voir Format du document › Vidéos). Les options du lecteur dans videoStyle.player s’appliquent à chaque vidéo : elles tournent en boucle, démarrent sans le son, se chargent en entier (27 et 61 Ko) et n’offrent ni téléchargement, ni image dans l’image, ni diffusion vers un autre écran : les vidéos restent sur la fiche.

#2 · Mettre en page l’édition écran à partir de la même source

script.js · lignes 172–180dans le code complet
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’édition écran vit dans la configuration sous htmlViewer.overrides, que le canvas et le PDF ignorent. À 122 dpi, chaque taille donnée en points sort environ un quart plus grande qu’à 96 : le texte de 9,4 pt se lit à 16 px dans le panneau, et les légendes, les encadrés et les tableaux grandissent avec lui. L’ouverture garde son titre et abandonne son bandeau et ses champs, qui appartiennent au papier.

#3 · Lire les vidéos dans le panneau

script.js · lignes 333–340dans le code complet
// 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 compose chaque ressource vidéo en <video> HTML5 avec les options du lecteur comme attributs : controls, muted, loop, playsinline, preload="auto", disablepictureinpicture, disableremoteplayback et controlslist="nodownload". Ce dernier ne fait que masquer le bouton. Quiconque a l’adresse peut toujours enregistrer le fichier : voyez-y une question de propreté, pas une protection. resourceVideoUrl fournit au moteur de rendu une adresse lisible pour chaque fileId, comme resourceImageUrl le fait pour les vignettes. C’est ici le fichier publié ; un site qui garde ses téléversements ailleurs renvoie sa propre URL, ou une URL blob des octets. Sans elle, le lecteur se rabat sur video.url, et sans aucune des deux, le panneau affiche la vignette.

#4 · Embarquer les vidéos dans un EPUB à mise en page fixe

script.js · lignes 290–310dans le code complet
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 fiche est construite autour de deux vidéos côte à côte et de cases vides où écrire : la mise en page fixe la conserve, chaque page telle qu’imprimée, avec un lecteur à la place de la vignette. Un EPUB recomposable lirait aussi les vidéos, l’une sous l’autre à la largeur du texte. resourceBytes répond video/mp4 pour chaque vidéo et un JPEG pour chaque vignette, et le générateur les range sous media/ et images/. L’EPUB ne garde que les attributs que connaît son schéma (controls, autoplay, muted, loop, playsinline, preload) : le bouton de téléchargement et le reste sont laissés au système de lecture. Le fichier passe EPUBCheck sans erreur ni avertissement.

#5 · Composer θ et π dans le canvas, le PDF et l’EPUB

Source Serif 4 range ses lettres grecques dans un fichier Fontsource à part, greek, à côté de latin. kitFonts(FONTS) trouve des lettres grecques dans le texte et charge ce fichier pour chaque style de la famille, et fontsourceProvider l’incorpore dans le PDF à côté du fichier latin pour chaque style qui compose θ ou π : les deux lettres ne demandent aucun code. L’EPUB n’incorpore que les polices que lui remet le pen : l’étape 4 liste donc les fichiers greek du caractère de texte après les fichiers latins, chacun limité au bloc grec par son unicodeRange.

#6 · Calculer le tableau dans le code

script.js · lignes 65–86dans le code complet
// 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]);

La période exacte demande l’intégrale elliptique complète K, et la moyenne arithmético-géométrique la donne en quelques étapes : T = T~0~ / AGM(1, cos ½θ~0~). Le tableau imprime trois décimales, avec la virgule décimale en espagnol, et les lignes de 10° et 80° correspondent aux vidéos : 2,010 s et 2,282 s, soit 0,2 % et 13,8 % de plus que T~0~ = 2,006 s. En 8 s, cela fait un peu moins de quatre oscillations complètes pour la première vidéo et trois et demie pour la seconde.

La recette complète

Sandbox
// ═══ 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`---
Exemple Markdown · 86 lignes · content.en.mdtitle: "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; // #endregion
Kit · core, fonts, viewer, pdf, images : identique dans toutes les recettes · 315 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Lancer une vidéo toute seule

Le player propre à une vidéo se superpose à videoStyle.player. La lecture automatique démarre toujours sans le son, comme l’exigent les navigateurs.

 ];
+clips[0].video.player = { autoplay: true }; // the 10° clip starts as the pane opens

#Afficher à l’écran la vignette imprimée

Avec html: 'poster', l’édition HTML compose la vignette avec son symbole de lecture et son QR code, reliée à l’adresse de production, à la place du lecteur.

 const videoStyle = {
+  html: 'poster',

#Utiliser une vidéo YouTube ou Vimeo

source: 'youtube' avec l’adresse de la vidéo intègre le lecteur de YouTube depuis youtube-nocookie.com (privacy est activé par défaut). Ce lecteur respecte moins d’options : commandes, plein écran, lecture automatique, son coupé et boucle ; celui de Vimeo ajoute le menu de vitesse et l’image dans l’image ; aucun des deux n’offre de téléchargement. Un EPUB ne peut pas intégrer le lecteur d’une page web : la vidéo y est donc sa vignette, reliée à la vidéo. Les options et leur portée sont listées dans Configuration › Style des vidéos.

Erreurs fréquentes

Erreur fréquente

renderToEpub n’incorpore que les polices que vous lui remettez

Les polices chargées par le navigateur ne sont pas des fichiers que postext-epub peut lire : passez dans options.fonts les octets de chaque style utilisé par les pages (les mêmes fichiers woff2 de Fontsource que la page a chargés). Un style omis est signalé comme missingFont, et le système de lecture compose ce texte dans une police à lui, qui, dans une mise en page fixe, ne tient plus dans les lignes avec lesquelles la page a été composée. N’incorporez que les polices dont la licence le permet. Export EPUB →

Erreur fréquente

Les fichiers Fontsource couvrent les lettres, pas les symboles

Le kit intègre le fichier latin Fontsource d’un caractère, plus son fichier latin-ext pour č, ł, † et son fichier greek pour α, χ (dans une famille qui en fournit un : ce n’est pas le cas de Lora et de Gelasio), quand le texte les compose, à l’écran et dans le PDF. Les symboles comme →, ≈, ✓ et ★ ne figurent dans aucun de ces fichiers et manquent dans le PDF : dessinez-les, composez-les en maths, ou choisissez un caractère dont les fichiers les contiennent. Polices intégrées au PDF →

Les MP4 sont en H.264. Chrome, Edge, Safari et Firefox les lisent, de même que le Chrome for Testing qu’utilise la capture. Un Chromium compilé sans ses codecs propriétaires, la configuration open source par défaut, ne les lit pas ; encodez les vidéos en WebM (format: 'webm') si cela compte pour vos lecteurs.

Crédits

Texte
Texte original, CC BY 4.0
Images
  • 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
Polices
Source Serif 4 (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
SandboxPDF