Saltar al contenido principal
Receta número 132

Recetario · Capítulo 10 · Salida e integración

Hoja de prácticas con vídeos que se ven en pantalla y EPUB

Dos recursos de vídeo con su propio MP4: el impreso pone su primer fotograma con un código QR; renderToHtml y postext-epub reproducen los archivos.

En esta página
  • Formato 210 × 297 mm
  • 2 columnas, medianil de 7 mm
  • Source Serif 4 9,4/13,2
  • Red Hat Display
  • Red Hat Mono
  • 2 páginas
  • Nivel
  • Postext 1.16.1
  • Compuesto en 148 ms
  • 250 líneas de código

En pocas palabras

Una hoja de prácticas de física con dos vídeos cortos de un péndulo. En papel cada vídeo es una imagen fija con un código que se escanea; en pantalla y en el libro electrónico el vídeo se reproduce en la página.

Lo que vas a componer

La práctica 4.2 de Física en el laboratorio, un cuaderno A4 para Bachillerato, sobre el periodo de un péndulo. Una banda de color teja lleva el título y los campos para el nombre, el grupo y la fecha. Al pie de la primera página, dos vídeos del mismo péndulo, soltado desde 10° y desde 80°, quedan uno junto al otro. En papel, cada vídeo es su primer fotograma con una marca de reproducción y un código QR que abre el MP4. En la edición de pantalla que acompaña a las páginas, y en el EPUB que escribe el código, esos mismos recursos se reproducen en el reproductor de vídeo del lector. La segunda página reúne el procedimiento, una tabla para rellenar, los periodos exactos que calcula el código y cuatro cuestiones con su recuadro para la respuesta.

Esta receta responde a

  • ¿Cómo reproducen la edición HTML y el EPUB los vídeos del libro, y cómo oculto el botón de descarga?
  • ¿Cómo pongo un vídeo en un libro impreso para que el lector pueda abrirlo desde la página?

La respuesta corta

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

Ingredientes

Tipografía
Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
Recursos
  • pendulum-large-poster.jpg
  • pendulum-large.mp4
  • pendulum-small-poster.jpg
  • pendulum-small.mp4
  • Vídeo 1: un péndulo de 1 m soltado desde 10°, 8 s a 25 fotogramas por segundo, generado a partir de una solución RK4 de la ecuación del péndulo (pasos de 1 ms) con Python y codificado con ffmpeg (Postext Cookbook, CC BY 4.0)
  • Vídeo 2: el mismo péndulo soltado desde 80°, generado y codificado del mismo modo (Postext Cookbook, CC BY 4.0)
  • El primer fotograma del vídeo 1, su portada impresa (Postext Cookbook, CC BY 4.0)
  • El primer fotograma del vídeo 2, su portada impresa (Postext Cookbook, CC BY 4.0)

Elaboración

#1 · Dale a cada vídeo una dirección de producción y un archivo

El código es la respuesta corta de arriba. Un vídeo propio tiene dos direcciones. video.url es donde lo encontrará el libro publicado: el código QR la imprime y el PDF enlaza la portada con ella, así que aquí es la dirección del vídeo en jsDelivr, la que un móvil puede abrir hoy. video.fileId nombra el archivo, que se guarda aparte como un mapa de bits, para las salidas que pueden llevarlo o reproducirlo desde otro sitio (ver Formato del documento › Vídeos). Las opciones de videoStyle.player valen para todos los vídeos: se repiten en bucle, empiezan sin sonido, se cargan enteros (27 y 61 KB) y no ofrecen descarga, imagen dentro de imagen ni envío a otra pantalla, de modo que el vídeo se queda en la hoja.

#2 · Compón la edición de pantalla con el mismo original

script.js · líneas 172–180en el código completo
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: [] } };

La edición de pantalla vive en la configuración, en htmlViewer.overrides, que el lienzo y el PDF no leen. A 122 ppp cada medida en puntos sale una cuarta parte mayor que a 96, así que el texto de 9,4 pt se lee a 16 px en el panel, y los pies, los recuadros y las tablas crecen con él. La apertura conserva el título y deja la banda y los campos, que son cosa del papel.

#3 · Reproduce los vídeos en el panel

script.js · líneas 345–352en el código completo
// 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 cada recurso de vídeo como un <video> de HTML5 con las opciones del reproductor como atributos: controls, muted, loop, playsinline, preload="auto", disablepictureinpicture, disableremoteplayback y controlslist="nodownload". Este último solo oculta el botón. Quien tenga la dirección puede guardar el archivo igualmente, así que es orden en la interfaz, no protección. resourceVideoUrl le da al motor una dirección reproducible para cada fileId, igual que resourceImageUrl hace con las portadas. Aquí es el recurso publicado; un sitio que guarde los archivos en otra parte devuelve su propia URL, o la URL de un blob con los bytes. Sin ella, el reproductor usa video.url, y sin ninguna de las dos el panel muestra la portada.

#4 · Mete los vídeos en un EPUB de maquetación fija

script.js · líneas 309–322en el código completo
const media = new Map(await Promise.all(clips.map(async ({ video }) =>
  [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }])));
const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts)
  ...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) =>
    ({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))),
  ...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first
].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) })));
const epub = await renderToEpub([doc], { layout: 'fixed', fonts,
  metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG,
    rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run
  // The clips as video/mp4 and the posters as pictures; the book packs both under media/.
  resourceBytes: (fileId) => media.get(fileId)
    ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' },
});

La hoja se organiza en torno a dos vídeos uno junto al otro y a recuadros vacíos para escribir, así que la maquetación fija la conserva: cada página como se imprime, con un reproductor donde estaba la portada. Un EPUB fluido también reproduciría los vídeos, uno debajo del otro y al ancho del texto. resourceBytes responde video/mp4 para cada vídeo y un JPEG para cada portada, y el escritor los guarda en media/ y en images/. El EPUB solo conserva los atributos que admite su esquema (controls, autoplay, muted, loop, playsinline, preload), de modo que el botón de descarga y lo demás quedan en manos del sistema de lectura. El archivo pasa EPUBCheck sin errores ni avisos.

#5 · Compón θ y π en el lienzo, el PDF y el EPUB

script.js · líneas 280–297en el código completo
// Fontsource cuts each face by script, and the PDF keeps its latin file (gotcha: latin-subset).
const GREEK = 'U+0370-03FF'; // Greek and Coptic
const file = (family, subset, [weight, style]) => 'https://cdn.jsdelivr.net/npm/@fontsource/'
  + `${fontsourceId(family)}@5/files/${fontsourceId(family)}-${subset}-${weight}-${style}.woff2`;
const faceOf = (spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'];
const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
const greek = FONTS[TEXT].map(faceOf).map(([weight, style]) => ({ family: TEXT, weight, style,
  url: file(TEXT, 'greek', [weight, style]) }));
const loadGreek = () => Promise.all(greek.map(async ({ weight, style, url }) => document.fonts.add(
  await new FontFace(TEXT, `url(${url})`, { weight: `${weight}`, style, unicodeRange: GREEK })
    .load())));
// The PDF takes both files of a text face; it asks for every weight, and Source Serif 4 ships
// 200 to 900 (gotcha: pdf-provider-all-styles). A character comes from the first file with it.
const pdfFont = async (family, weight, style) => {
  const latin = await fontsourceProvider(family, weight, style);
  return family !== TEXT ? latin : [latin, await decompressWoff2(await bytesOf(file(TEXT,
    'greek', [Math.max(weight, 200), style])))];
};

El kit carga el archivo latin de Fontsource de cada fuente, que no trae griego. El código añade los archivos greek de Source Serif 4 como fuentes limitadas al bloque griego, le pasa al PDF los dos archivos de cada fuente del texto (cada carácter sale del primero que lo tiene) y le da al EPUB los archivos griegos después de los latinos, cada uno con su unicodeRange. La captura sigue avisando de que θ y π quedan fuera del subconjunto latino; el PDF los lleva.

#6 · Calcula la tabla en el código

script.js · líneas 65–86en el código completo
// The arithmetic-geometric mean gives the elliptic integral K of the exact period in a few steps.
const T0 = 2 * Math.PI * Math.sqrt(1 / 9.81); // s: L = 1 m, g = 9.81 m/s²
const agm = (a, b) => (Math.abs(a - b) < 1e-15 ? a : agm((a + b) / 2, Math.sqrt(a * b)));
const period = (degrees) => T0 / agm(1, Math.cos((degrees * Math.PI) / 360));
const num = (x, digits) => x.toFixed(digits).replace('.', t({ en: '.', es: ',' }));
const cell = (content, align = 'right') => ({ content, align, verticalAlign: 'middle' });
const table = (id, caption, head, rows, columnWidths) => ({ id, typeId: 'table', kind: 'table',
  createdAt: 0, updatedAt: 0, caption: t(caption), placement: { position: 'here' },
  table: { model: { headerRowCount: 1, columnWidths, rows: [head.map((h, i) =>
    ({ ...cell(h, i ? 'right' : 'left'), isHeader: true })), ...rows] } } });
const periods = table('periods', { en: 'Exact periods of a pendulum 1.00 m long.',
  es: 'Periodos exactos de un péndulo de 1,00 m.' },
['Amplitude|Amplitud', 'T (s)', 'T/T~0~', 'Longer by|Aumento'].map((h) =>
  t({ en: h.split('|')[0], es: h.split('|').at(-1) })),
[5, 10, 20, 30, 45, 60, 80, 90].map((d) => [cell(`${d}°`, 'left'), cell(num(period(d), 3)),
  cell(num(period(d) / T0, 3)), cell(`${num((period(d) / T0 - 1) * 100, 2)} %`)]),
[1.2, 1, 1, 1.1]);
const blank = cell(' \n '); // two empty lines: room to write
const data = table('data', { en: 'Your measurements.', es: 'Tus medidas.' },
  t({ en: ['Clip', 'Swings in 8 s', 'T (s)'], es: ['Vídeo', 'Oscilaciones en 8 s', 'T (s)'] }),
  [10, 80].map((d, i) => [cell(`${t({ en: 'Video', es: 'Vídeo' })} ${i + 1} · ${d}°`, 'left'),
    blank, blank]), [1.2, 1.5, 0.8]);

El periodo exacto necesita la integral elíptica completa K, y la media aritmético-geométrica la da en unos pocos pasos: T = T~0~ / AGM(1, cos ½θ~0~). La tabla imprime tres decimales con coma decimal en español, y las filas de 10° y 80° coinciden con los vídeos: 2,010 s y 2,282 s, un 0,2 % y un 13,8 % más que T~0~ = 2,006 s. En 8 s salen casi cuatro oscilaciones completas en el primer vídeo y tres y media en el segundo.

La receta completa

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.16.1
import {
  buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides,
  clearMeasurementCache, registerResourceImage, defaultResourceTypes, resourceVideoLink,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { renderToEpub, readEpub } from 'https://esm.sh/postext-epub';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'pendulum-lab-video-players';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // the clips' own colours: a cream ground, the rust bob, warm black ink
  ink: '#22201b', // text, the play mark, the QR modules, table heads
  rust: '#a8432b', // the accent: the band, labels, list numbers (the bob's red, darker)
  cream: '#f6f1e4', rule: '#cbc2ae', // box fills, a shade deeper than the clips; hairlines
  muted: '#68625a', paper: '#ffffff' }; // muted: running heads, notes, prompts
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.rust }) // defaults: rust
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, MONO] = ['Source Serif 4', 'Red Hat Display', 'Red Hat Mono'];
const [TOP, INNER, OUTER, BAND, LEAD] = [22, 20, 17, 84, 13.2]; // mm, A4; LEAD in pt
const px = (value) => ({ value, unit: 'px' }); // the screen edition's sizes
const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x, y }, size });
const label = (size, color = 'rust') => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: 600,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(color) });

// #region answer: two clips of the book's own: a poster and a QR code, or a player
const clip = (id, file, poster, caption, altText) => ({
  id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0, caption: t(caption),
  note: t({ en: '8.00 s, 25 frames per second · Postext Cookbook, CC BY 4.0',
    es: '8,00 s, 25 fotogramas por segundo · Postext Cookbook, CC BY 4.0' }),
  altText: t(altText), placement: { position: 'bottom', span: 'column' },
  video: { source: 'file', fileId: file, format: 'mp4', width: 640, height: 360, duration: 8,
    url: asset(file), // the production address: printed as the QR code, linked from the PDF
    poster: { fileId: poster, format: 'jpeg', width: 640, height: 360 }, // the first frame
  },
});
const videoStyle = {
  playMark: { shape: 'rounded', position: 'top-left', size: mm(7), inset: mm(3),
    background: col('ink'), color: col('paper') },
  qr: { position: 'bottom-right', size: mm(19), inset: mm(2.5), // 111 characters: 45 modules
    color: col('ink'), background: col('paper'), radius: mm(1) },
  // The HTML5 player of a file honours every option. download: false writes
  // controlslist="nodownload": it hides the button, and the file stays at its address.
  player: { download: false, loop: true, muted: true, preload: 'auto',
    pictureInPicture: false, remotePlayback: false },
};
const clips = [
  clip('small', 'pendulum-small.mp4', 'pendulum-small-poster.jpg',
    { en: 'Released from rest at 10°.', es: 'Soltado desde el reposo a 10°.' },
    { en: 'A rust bob on a dark string hangs a few degrees off the vertical, under a short bar.',
      es: 'Una lenteja roja cuelga de un hilo oscuro a pocos grados de la vertical.' }),
  clip('large', 'pendulum-large.mp4', 'pendulum-large-poster.jpg',
    { en: 'Released from rest at 80°.', es: 'Soltado desde el reposo a 80°.' },
    { en: 'The same pendulum pulled out almost level with its pivot, a pale arc below it.',
      es: 'El mismo péndulo, casi a la altura de su eje, sobre un arco pálido.' }),
];
// #endregion

// #region periods: Table 2 is worked out here, not typed: T = T0 / AGM(1, cos ½θ0)
// The arithmetic-geometric mean gives the elliptic integral K of the exact period in a few steps.
const T0 = 2 * Math.PI * Math.sqrt(1 / 9.81); // s: L = 1 m, g = 9.81 m/s²
const agm = (a, b) => (Math.abs(a - b) < 1e-15 ? a : agm((a + b) / 2, Math.sqrt(a * b)));
const period = (degrees) => T0 / agm(1, Math.cos((degrees * Math.PI) / 360));
const num = (x, digits) => x.toFixed(digits).replace('.', t({ en: '.', es: ',' }));
const cell = (content, align = 'right') => ({ content, align, verticalAlign: 'middle' });
const table = (id, caption, head, rows, columnWidths) => ({ id, typeId: 'table', kind: 'table',
  createdAt: 0, updatedAt: 0, caption: t(caption), placement: { position: 'here' },
  table: { model: { headerRowCount: 1, columnWidths, rows: [head.map((h, i) =>
    ({ ...cell(h, i ? 'right' : 'left'), isHeader: true })), ...rows] } } });
const periods = table('periods', { en: 'Exact periods of a pendulum 1.00 m long.',
  es: 'Periodos exactos de un péndulo de 1,00 m.' },
['Amplitude|Amplitud', 'T (s)', 'T/T~0~', 'Longer by|Aumento'].map((h) =>
  t({ en: h.split('|')[0], es: h.split('|').at(-1) })),
[5, 10, 20, 30, 45, 60, 80, 90].map((d) => [cell(`${d}°`, 'left'), cell(num(period(d), 3)),
  cell(num(period(d) / T0, 3)), cell(`${num((period(d) / T0 - 1) * 100, 2)} %`)]),
[1.2, 1, 1, 1.1]);
const blank = cell(' \n '); // two empty lines: room to write
const data = table('data', { en: 'Your measurements.', es: 'Tus medidas.' },
  t({ en: ['Clip', 'Swings in 8 s', 'T (s)'], es: ['Vídeo', 'Oscilaciones en 8 s', 'T (s)'] }),
  [10, 80].map((d, i) => [cell(`${t({ en: 'Video', es: 'Vídeo' })} ${i + 1} · ${d}°`, 'left'),
    blank, blank]), [1.2, 1.5, 0.8]);
// #endregion

// #region opener: a rust band with the sheet's number, its title and the fields to fill in
const words = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // titles wrap (gotcha: overflow-ellipsis-default)
const names = t({ en: ['Name', 'Group', 'Date'], es: ['Nombre', 'Grupo', 'Fecha'] });
const fields = [[0, 86], [90, 30], [124, 49]].flatMap(([x, width], i) => [ // mm, from the margin
  words(`label-${i}`, names[i], MONO, 7, at('page', 'top-left', mm(INNER + x), mm(BAND - 16)),
    label(7, 'paper')),
  { kind: 'box', id: `field-${i}`, style: { backgroundColor: col('paper'), borderRadius: mm(1) },
    placement: at('page', 'top-left', mm(INNER + x), mm(BAND - 12.5),
      { width: mm(width), height: mm(6.5) }) }]);
const opener = { enabled: true, minHeight: mm(BAND - TOP + 6), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('rust') },
    placement: at('bleed', 'top-left', mm(0), mm(0), { width: 'fill', height: mm(BAND) }) },
  words('kicker', '{attr.kicker}', MONO, 8.5, at('page', 'top-left', mm(INNER), mm(14)),
    label(8.5, 'paper')),
  words('title', '{titleText}', DISPLAY, 46, at('#kicker', 'below', mm(0), mm(2.5),
    { width: mm(150) }), { fontWeight: 800, lineHeight: 0.98 }),
  words('lead', '{attr.lead}', TEXT, 10, at('#title', 'below', mm(0), mm(3.5),
    { width: mm(158) }), { lineHeight: 1.4 }),
  ...fields] } }; // a label over a white field for each, to fill in by hand
// #endregion

const head = (id, content, edge, x, extra) => ({ kind: 'text', id, content, parity: 'even',
  pages: 'body', ...label(7, 'muted'), fontWeight: 500, placement: at('page', edge, mm(x),
    mm(12)), ...extra });
const header = { elements: [
  head('folio', '{pageNumber}', 'top-left', OUTER, { color: col('rust'), fontWeight: 700 }),
  head('title', '{title} · {attr.kicker}', 'top-left', OUTER + 7)] };
const footer = { elements: [head('drop-folio', '{title} · {pageNumber}', 'bottom-right', 0, {
  parity: 'all', pages: 'opener', placement: at('page', 'bottom-right', mm(-OUTER), mm(-11)) })] };
const box = (id, extra) => ({ id, background: col('cream'), borderRadius: mm(1.5),
  padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) },
  titleStyle: { ...label(7.5), gap: mm(1.2) }, body: { fontSize: pt(9.2), lineHeight: pt(LEAD),
    textAlign: 'left', firstLineIndent: pt(0) }, ...extra });

const config = () => ({ // a factory: the engine caches configs by identity
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  // Figure, Table, Video in the sheet's language, counted 1, 2… (gotcha: resource-types-locale)
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never', shortLabel: t({ en: type.name, es: type.name.toLowerCase() }) })),
  colorPalette, videoStyle,
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: {
    top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // columns of 83 mm
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rust'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 700, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(LEAD / 2) },
  ] },
  orderedLists: { numberFormat: 'arabic', fontFamily: MONO, fontWeight: 700,
    color: col('rust'), marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [
    box('aim', { stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rust') } }),
    box('method'), box('sheet'),
    { id: 'answer', background: col('paper'), borderRadius: mm(1), marginTop: mm(1.2),
      marginBottom: mm(2.5), border: { enabled: true, color: col('rule'), width: pt(0.6) },
      padding: { top: mm(1), right: mm(2.5), bottom: mm(1), left: mm(2.5) },
      body: { ...label(6.5, 'muted'), fontWeight: 500, lineHeight: pt(LEAD) } },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: MONO,
    headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(8.5),
    bodyColor: col('ink'), cellPadding: mm(1.4) },
  captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), labelBold: true,
    labelColor: col('rust'), gap: mm(2), note: { fontSize: pt(7), color: col('muted') } },
  paragraphStyles: [
    { id: 'formula', fontSize: pt(11), textAlign: 'center', firstLineIndent: pt(0),
      marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: MONO, fontSize: pt(6.5), lineHeight: pt(9.5),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header, footer, htmlViewer: { overrides: screen }, // canvas and PDF ignore the overrides
});

// #region screen: the screen edition: one column at the pane's width, the clips as players
const [PANE_W, PANE_H] = [400, 640]; // px
const screen = {
  page: { width: px(PANE_W), height: px(PANE_H), dpi: 122, // so the 9.4 pt text is 16 px
    margins: { top: px(28), bottom: px(28), left: px(22), right: px(22), mirror: false } },
  layout: { layoutType: 'single' }, bodyText: { textAlign: 'left' }, // ragged in a narrow pane
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    fontSize: px(28), lineHeight: px(32), fontWeight: 800, marginBottom: px(16),
    advancedDesign: { enabled: false } }] }, // the title alone: the band and fields are print's
  header: { elements: [] }, footer: { elements: [] } };
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 86 líneas · content.es.mdtitle: "Física en el laboratorio" subtitle: "Cuaderno de prácticas, de 16 a 18 años" --- # El periodo de un péndulo {kicker="Unidad 4 · Oscilaciones · Práctica 4.2" lead="Dos péndulos de la misma longitud, soltados desde 10° y desde 80°. Cronometra los dos en vídeo y averigua si el periodo depende de la amplitud de la oscilación."} :::callout{type="aim" title="Objetivo"} Medir en vídeo el periodo de un péndulo de 1,00 m y ver hasta dónde vale la fórmula de las oscilaciones pequeñas. **Necesitas** los vídeos, un móvil o una pantalla y una calculadora. ::: ## Los dos vídeos El :ref{id="small"} y el :ref{id="large"} se generaron a partir de una solución numérica de la ecuación del movimiento del péndulo, θ″ = −(*g*/*L*) sen θ, calculada en pasos de un milisegundo para un péndulo de 1,00 m. Cada uno dura 8,00 s a 25 fotogramas por segundo, así que un fotograma son 0,04 s. El modelo no tiene rozamiento, así que las oscilaciones no se apagan. La imagen impresa de cada vídeo es su primer fotograma, y el código de la esquina lo abre. Escanéalo con el móvil o mira los vídeos en la edición de pantalla. Los dos se repiten en bucle, empiezan sin sonido y tienen un menú de velocidad: bájala a la mitad para contar. ## Fundamento Un péndulo simple es una lenteja pequeña y pesada colgada de un hilo ligero que no se estira. Si se aparta y se suelta, oscila a uno y otro lado de su punto más bajo. El **periodo**, *T*, es el tiempo que tarda en ir y volver al punto de partida. Galileo observó que un péndulo conserva el periodo mientras sus oscilaciones se apagan, y los relojeros se apoyaron en ello durante tres siglos. En oscilaciones pequeñas, el periodo solo depende de la longitud *L* y de la intensidad del campo gravitatorio *g*: :::paragraphs{style="formula"} *T*~0~ = 2π (*L*/*g*)^1/2^ ::: Con *L* = 1,00 m y *g* = 9,81 m s^−2^, *T*~0~ vale 2,006 s. Para deducirla se sustituye sen θ por θ en radianes, lo que solo vale con un error menor del 1 % hasta unos 14°. Más allá, la fuerza que devuelve la lenteja al centro crece más despacio que el ángulo, la lenteja se entretiene cerca de los extremos y cada oscilación tarda algo más. Con la amplitud θ~0~, el ángulo desde el que se suelta la lenteja, y *K*, la integral elíptica completa de primera especie, el periodo exacto es :::paragraphs{style="formula"} *T* = 4 (*L*/*g*)^1/2^ *K*(sen ½θ~0~) ::: ## Procedimiento :::callout{type="method"} 1. Reproduce el primer vídeo desde el principio. La lenteja se suelta desde el reposo en el primer fotograma. 2. Cuenta las oscilaciones completas, de ida y vuelta al punto de partida, hasta que el vídeo acaba a los 8,00 s. Estima la última con una precisión de un cuarto de oscilación. 3. Divide 8,00 s entre el número de oscilaciones para obtener el periodo. 4. Repite con el segundo vídeo y completa la tabla. ::: ## Resultados ::resource{id="data"} Los valores de la :ref{id="periods"} salen de la fórmula exacta, con tres decimales. Compara tus periodos con las filas de 10° y de 80°. ::resource{id="periods"} ## Cuestiones :::callout{type="sheet"} 1. ¿Qué vídeo tiene el periodo más largo, y en qué porcentaje? :::callout{type="answer"} Respuesta :::space{lines=3} ::: 2. La lenteja del segundo vídeo recorre un arco mucho más largo. ¿Por qué su periodo no crece en la misma proporción? :::callout{type="answer"} Respuesta :::space{lines=4} ::: 3. La primera corrección a la fórmula de las oscilaciones pequeñas da *T* = *T*~0~ (1 + θ~0~^2^/16), con θ~0~ en radianes. Calcúlala para 80° y compárala con la tabla. ¿Basta con un término? :::callout{type="answer"} Respuesta :::space{lines=4} ::: 4. Un reloj de pie oscila 4° a cada lado de la vertical. ¿Necesitaría su relojero la fórmula exacta? :::callout{type="answer"} Respuesta :::space{lines=3} ::: ::: :::paragraphs{style="colophon"} Física en el laboratorio, práctica 4.2 · Compuesto en Source Serif 4, Red Hat Display y Red Hat Mono (SIL Open Font License) · Texto y vídeos: Postext Cookbook, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook const resources = [...clips, data, periods]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Source Serif 4': ['400', '400i', '700', '700i'], 'Red Hat Display': ['700', '800'], 'Red Hat Mono': ['400', '500', '600', '700'] }; // #region greek: θ and π come from the text face's greek file, which the kit does not load // Fontsource cuts each face by script, and the PDF keeps its latin file (gotcha: latin-subset). const GREEK = 'U+0370-03FF'; // Greek and Coptic const file = (family, subset, [weight, style]) => 'https://cdn.jsdelivr.net/npm/@fontsource/' + `${fontsourceId(family)}@5/files/${fontsourceId(family)}-${subset}-${weight}-${style}.woff2`; const faceOf = (spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']; const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()); const greek = FONTS[TEXT].map(faceOf).map(([weight, style]) => ({ family: TEXT, weight, style, url: file(TEXT, 'greek', [weight, style]) })); const loadGreek = () => Promise.all(greek.map(async ({ weight, style, url }) => document.fonts.add( await new FontFace(TEXT, `url(${url})`, { weight: `${weight}`, style, unicodeRange: GREEK }) .load()))); // The PDF takes both files of a text face; it asks for every weight, and Source Serif 4 ships // 200 to 900 (gotcha: pdf-provider-all-styles). A character comes from the first file with it. const pdfFont = async (family, weight, style) => { const latin = await fontsourceProvider(family, weight, style); return family !== TEXT ? latin : [latin, await decompressWoff2(await bytesOf(file(TEXT, 'greek', [Math.max(weight, 200), style])))]; }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadGreek(), ...clips.map(({ video }) => loadImage(video.poster.fileId, asset(video.poster.fileId)))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'The period of a pendulum', es: 'El periodo de un péndulo' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: pdfFont, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #region epub: a fixed-layout EPUB that carries both MP4 files and plays them on the page const media = new Map(await Promise.all(clips.map(async ({ video }) => [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }]))); const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts) ...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) => ({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))), ...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first ].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) }))); const epub = await renderToEpub([doc], { layout: 'fixed', fonts, metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG, rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run // The clips as video/mp4 and the posters as pictures; the book packs both under media/. resourceBytes: (fileId) => media.get(fileId) ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' }, }); // #endregion const [read, kb] = [readEpub(epub), (bytes) => `${Math.round(bytes.length / 1024)} KB`]; const list = (lines) => `<ul>${lines.map((line) => `<li>${line}</li>`).join('')}</ul>`; const desk = Object.assign(document.createElement('section'), { id: 'editions', innerHTML: ` <style>#editions{display:flex;flex-wrap:wrap;gap:28px;justify-content:center;align-items:start; padding:28px 16px 0;color:#d9d5cc}#editions figure{margin:0}#editions figcaption{margin-top:16px; color:#8b8f97}#screen{width:${PANE_W}px;height:${PANE_H}px;overflow:auto;border-radius:12px; box-shadow:0 0 0 8px #1d1f24}#editions article{width:min(380px,92vw);padding:4px 18px; border-radius:8px;background:#15181d;overflow-wrap:anywhere}#editions a{color:#d8a21a}</style> <figure><div id="screen" role="region" tabindex="0"></div><figcaption>${t({ en: 'Screen' + ' edition', es: 'Edición de pantalla' })} · HTML · ${PANE_W} × ${PANE_H} px</figcaption> </figure><article><h3>EPUB 3 · ${read.layout}</h3> <p>${read.spine.length} pages · ${kb(epub)}. The book carries:</p>${list([...read.manifest .values()].filter((item) => item.mediaType === 'video/mp4').map(({ path }) => `${path.slice(read.root.length)} · ${kb(read.files.get(path))}`))} <p>The QR codes and the PDF links open:</p>${list(clips.map(resourceVideoLink))} <a download="${RECIPE}.epub" href="${URL.createObjectURL(new Blob([epub], { type: 'application/epub+zip' }))}">Download ${RECIPE}.epub</a></article>` }); document.getElementById('pages').before(desk); // #region pane: the screen edition as HTML, each clip in the browser's own player // A shadow root keeps the page's styles out, so the pane shows the lines as they were measured. const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' }); pane.innerHTML = renderToHtml(buildDocument({ markdown, resources }, applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff', resourceImageUrl: imageUrl, // the posters, until a clip plays resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48; // #endregion
Kit · core, fonts, viewer, pdf, images: igual en todas las recetas · 310 líneas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

Variantes

#Haz que un vídeo empiece solo

El player de cada vídeo se superpone a videoStyle.player. La reproducción automática siempre empieza sin sonido, como exigen los navegadores.

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

#Muestra la portada impresa en pantalla

Con html: 'poster', la edición HTML compone la portada con su marca de reproducción y su código QR, enlazada a la dirección de producción, en lugar del reproductor.

 const videoStyle = {
+  html: 'poster',

#Usa un vídeo de YouTube o de Vimeo

source: 'youtube' con la dirección del vídeo inserta el reproductor de YouTube desde youtube-nocookie.com (privacy está activado por defecto). Ese reproductor atiende menos opciones: controles, pantalla completa, reproducción automática, silencio y bucle; el de Vimeo añade el menú de velocidad y la imagen dentro de imagen, y ninguno ofrece descarga. Un EPUB no puede insertar el reproductor de una página web, así que allí el vídeo es su portada enlazada. Las opciones y su alcance están en Configuración › Estilo de vídeo.

Errores frecuentes

Error frecuente

renderToEpub solo incrusta las fuentes que le pasas

Las fuentes que ha cargado el navegador no son archivos que postext-epub pueda leer: pasa en options.fonts los bytes de cada fuente que usan las páginas (los mismos woff2 de Fontsource que cargó la página). Una fuente que falte se avisa como missingFont, y el sistema de lectura compone ese texto con una suya, que en una maquetación fija ya no cabe en las líneas con que se compuso la página. Incrusta solo fuentes cuya licencia lo permita. Exportación a EPUB →

Error frecuente

Los archivos latin de Fontsource solo traen glifos del rango latino

El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin. Fuentes incrustadas en el PDF →

Error frecuente

El PDF pide todos los pesos y estilos de cada familia

renderToPdf pide al proveedor de fuentes la negrita, la cursiva y la negrita cursiva de cada familia que un bloque podría usar, aunque nunca se imprima, y un solo rechazo detiene la exportación. El proveedor debe ajustarse al peso más cercano que tenga la familia y volver a la redonda cuando no haya cursiva. Fuentes incrustadas en el PDF →

Error frecuente

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

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

Error frecuente

Carga todas las fuentes antes de componer

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

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

Error frecuente

Traduce Figura y Tabla con defaultResourceTypes(locale)

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

Error frecuente

Solo 8 idiomas tienen separación silábica, con el código exacto

La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →

Error frecuente

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

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

Los MP4 son H.264. Chrome, Edge, Safari y Firefox los reproducen, y también el Chrome for Testing de la captura. Un Chromium compilado sin sus códecs propietarios, que es lo que hace por defecto el proyecto abierto, no los reproduce; codifica los vídeos en WebM (format: 'webm') si eso importa a tus lectores.

Créditos

Texto
Texto original, CC BY 4.0
Imágenes
  • Vídeo 1: un péndulo de 1 m soltado desde 10°, 8 s a 25 fotogramas por segundo, generado a partir de una solución RK4 de la ecuación del péndulo (pasos de 1 ms) con Python y codificado con ffmpeg · Postext Cookbook · CC BY 4.0
  • Vídeo 2: el mismo péndulo soltado desde 80°, generado y codificado del mismo modo · Postext Cookbook · CC BY 4.0
  • El primer fotograma del vídeo 1, su portada impresa · Postext Cookbook · CC BY 4.0
  • El primer fotograma del vídeo 2, su portada impresa · Postext Cookbook · CC BY 4.0
Fuentes
Source Serif 4 (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
SandboxPDF