# 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.

- Versión HTML: https://postext.dev/es/cookbook/pendulum-lab-video-players
- Receta N.º 132 · Salida e integración · Nivel 3 (Avanzado) · Salidas: Canvas, HTML, PDF, EPUB
- Géneros: Cuadernos y ejercicios, Libros de texto
- Requiere postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · probada con 1.16.1, postext-pdf 1.16.1 el 2026-10-05
- Páginas: [1](https://postext.dev/cookbook/pendulum-lab-video-players/es/p01.webp?v=cb09dec3), [2](https://postext.dev/cookbook/pendulum-lab-video-players/es/p02.webp?v=cb09dec3)
- PDF: https://postext.dev/cookbook/pendulum-lab-video-players/es/pendulum-lab-video-players.pdf?v=cb09dec3
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=pendulum-lab-video-players&lang=es (.postext: https://postext.dev/cookbook/pendulum-lab-video-players/es/pendulum-lab-video-players.postext)
- Última actualización: 2026-10-05
- Otros idiomas: [en](https://postext.dev/en/cookbook/pendulum-lab-video-players.md), [ca](https://postext.dev/ca/cookbook/pendulum-lab-video-players.md), [zh](https://postext.dev/zh/cookbook/pendulum-lab-video-players.md), [ja](https://postext.dev/ja/cookbook/pendulum-lab-video-players.md), [ar](https://postext.dev/ar/cookbook/pendulum-lab-video-players.md)

## 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 de pantalla 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

```js
// script.js, líneas 32–61
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

**Enseña**

- [Portadas de vídeo: marca de reproducción y código QR](https://postext.dev/es/docs/configuration.md#estilo-de-vídeo): videoStyle: la marca de reproducción y el código QR que se imprimen sobre la portada de un vídeo, el enlace de la portada y las opciones del reproductor en HTML y EPUB.
- [Vídeos como recursos](https://postext.dev/es/docs/document-format.md#vídeos): Vídeos de YouTube, de Vimeo o propios que se colocan, se rotulan y se numeran como figuras (Vídeo 1.1); el impreso muestra la portada y el visor HTML y el EPUB los reproducen.

**También usa**

- [Exportación a EPUB](https://postext.dev/es/docs/configuration.md#libros-epub-postext-epub)
- [Edición de lectura en HTML](https://postext.dev/es/docs/configuration.md#integrar-el-visor-html)
- [Un diseño más sencillo para pantalla](https://postext.dev/es/docs/configuration.md#visor-html)
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilo de tablas](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Recuadros anidados](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Superíndices y subíndices](https://postext.dev/es/docs/document-format.md#formato-en-línea)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`htmlViewer`](https://postext.dev/es/docs/configuration.md#visor-html), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`videoStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-vídeo)

**API**

- [`applyHtmlViewerOverrides`](https://postext.dev/es/docs/configuration.md#visor-html), [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`readEpub`](https://postext.dev/es/docs/configuration.md#escribir-un-libro), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToEpub`](https://postext.dev/es/docs/configuration.md#escribir-un-libro), [`renderToHtml`](https://postext.dev/es/docs/configuration.md#integrar-el-visor-html), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf), [`resourceVideoLink`](https://postext.dev/es/docs/document-format.md#vídeos)

**Tipografías**

- Source Serif 4 (OFL-1.1), Red Hat Display (OFL-1.1), Red Hat Mono (OFL-1.1)

## Elaboración

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

El código es [la respuesta corta](#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](/es/docs/document-format#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

```js
// script.js, líneas 172–180
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

```js
// script.js, líneas 345–352
// 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

```js
// script.js, líneas 309–322
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

```js
// script.js, líneas 280–297
// 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

```js
// script.js, líneas 65–86
// 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

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/pendulum-lab-video-players

### script.js

```js
// ═══ 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`---
title: "Física en el laboratorio"
subtitle: "Cuaderno de prácticas, de 16 a 18 años"
---

# El periodo de un péndulo {kicker="Unidad 4 · Oscilaciones · Práctica 4.2" lead="Dos péndulos de la misma longitud, soltados desde 10° y desde 80°. Cronometra los dos en vídeo y averigua si el periodo depende de la amplitud de la oscilación."}

:::callout{type="aim" title="Objetivo"}
Medir en vídeo el periodo de un péndulo de 1,00 m y ver hasta dónde vale la fórmula de las oscilaciones pequeñas.

**Necesitas** los vídeos, un móvil o una pantalla y una calculadora.
:::

## Los dos vídeos

El :ref{id="small"} y el :ref{id="large"} se generaron a partir de una solución numérica de la ecuación del movimiento del péndulo, θ″ = −(*g*/*L*) sen θ, calculada en pasos de un milisegundo para un péndulo de 1,00 m. Cada uno dura 8,00 s a 25 fotogramas por segundo, así que un fotograma son 0,04 s. El modelo no tiene rozamiento, así que las oscilaciones no se apagan.

La imagen impresa de cada vídeo es su primer fotograma, y el código de la esquina lo abre. Escanéalo con el móvil o mira los vídeos en la edición de pantalla. Los dos se repiten en bucle, empiezan sin sonido y tienen un menú de velocidad: bájala a la mitad para contar.

## Fundamento

Un péndulo simple es una lenteja pequeña y pesada colgada de un hilo ligero que no se estira. Si se aparta y se suelta, oscila a uno y otro lado de su punto más bajo. El **periodo**, *T*, es el tiempo que tarda en ir y volver al punto de partida.

Galileo observó que un péndulo conserva el periodo mientras sus oscilaciones se apagan, y los relojeros se apoyaron en ello durante tres siglos. En oscilaciones pequeñas, el periodo solo depende de la longitud *L* y de la intensidad del campo gravitatorio *g*:

:::paragraphs{style="formula"}
*T*~0~ = 2π (*L*/*g*)^1/2^
:::

Con *L* = 1,00 m y *g* = 9,81 m s^−2^, *T*~0~ vale 2,006 s. Para deducirla se sustituye sen θ por θ en radianes, lo que solo vale con un error menor del 1 % hasta unos 14°. Más allá, la fuerza que devuelve la lenteja al centro crece más despacio que el ángulo, la lenteja se entretiene cerca de los extremos y cada oscilación tarda algo más. Con la amplitud θ~0~, el ángulo desde el que se suelta la lenteja, y *K*, la integral elíptica completa de primera especie, el periodo exacto es

:::paragraphs{style="formula"}
*T* = 4 (*L*/*g*)^1/2^ *K*(sen ½θ~0~)
:::

## Procedimiento

:::callout{type="method"}
1. Reproduce el primer vídeo desde el principio. La lenteja se suelta desde el reposo en el primer fotograma.
2. Cuenta las oscilaciones completas, de ida y vuelta al punto de partida, hasta que el vídeo acaba a los 8,00 s. Estima la última con una precisión de un cuarto de oscilación.
3. Divide 8,00 s entre el número de oscilaciones para obtener el periodo.
4. Repite con el segundo vídeo y completa la tabla.
:::

## Resultados

::resource{id="data"}

Los valores de la :ref{id="periods"} salen de la fórmula exacta, con tres decimales. Compara tus periodos con las filas de 10° y de 80°.

::resource{id="periods"}

## Cuestiones

:::callout{type="sheet"}
1. ¿Qué vídeo tiene el periodo más largo, y en qué porcentaje?

:::callout{type="answer"}
Respuesta
:::space{lines=3}
:::

2. La lenteja del segundo vídeo recorre un arco mucho más largo. ¿Por qué su periodo no crece en la misma proporción?

:::callout{type="answer"}
Respuesta
:::space{lines=4}
:::

3. La primera corrección a la fórmula de las oscilaciones pequeñas da *T* = *T*~0~ (1 + θ~0~^2^/16), con θ~0~ en radianes. Calcúlala para 80° y compárala con la tabla. ¿Basta con un término?

:::callout{type="answer"}
Respuesta
:::space{lines=4}
:::

4. Un reloj de pie oscila 4° a cada lado de la vertical. ¿Necesitaría su relojero la fórmula exacta?

:::callout{type="answer"}
Respuesta
:::space{lines=3}
:::
:::

:::paragraphs{style="colophon"}
Física en el laboratorio, práctica 4.2 · Compuesto en Source Serif 4, Red Hat Display y Red Hat Mono (SIL Open Font License) · Texto y vídeos: Postext Cookbook, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
const resources = [...clips, data, periods];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Source Serif 4': ['400', '400i', '700', '700i'], 'Red Hat Display': ['700', '800'],
  'Red Hat Mono': ['400', '500', '600', '700'] };

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

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown), loadGreek(),
  ...clips.map(({ video }) => loadImage(video.poster.fileId, asset(video.poster.fileId)))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'The period of a pendulum', es: 'El periodo de un péndulo' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: pdfFont, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// #region epub: a fixed-layout EPUB that carries both MP4 files and plays them on the page
const media = new Map(await Promise.all(clips.map(async ({ video }) =>
  [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }])));
const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts)
  ...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) =>
    ({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))),
  ...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first
].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) })));
const epub = await renderToEpub([doc], { layout: 'fixed', fonts,
  metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG,
    rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run
  // The clips as video/mp4 and the posters as pictures; the book packs both under media/.
  resourceBytes: (fileId) => media.get(fileId)
    ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' },
});
// #endregion

const [read, kb] = [readEpub(epub), (bytes) => `${Math.round(bytes.length / 1024)} KB`];
const list = (lines) => `<ul>${lines.map((line) => `<li>${line}</li>`).join('')}</ul>`;
const desk = Object.assign(document.createElement('section'), { id: 'editions', innerHTML: `
<style>#editions{display:flex;flex-wrap:wrap;gap:28px;justify-content:center;align-items:start;
padding:28px 16px 0;color:#d9d5cc}#editions figure{margin:0}#editions figcaption{margin-top:16px;
color:#8b8f97}#screen{width:${PANE_W}px;height:${PANE_H}px;overflow:auto;border-radius:12px;
box-shadow:0 0 0 8px #1d1f24}#editions article{width:min(380px,92vw);padding:4px 18px;
border-radius:8px;background:#15181d;overflow-wrap:anywhere}#editions a{color:#d8a21a}</style>
<figure><div id="screen" role="region" tabindex="0"></div><figcaption>${t({ en: 'Screen'
  + ' edition', es: 'Edición de pantalla' })} · HTML · ${PANE_W} × ${PANE_H} px</figcaption>
</figure><article><h3>EPUB 3 · ${read.layout}</h3>
<p>${read.spine.length} pages · ${kb(epub)}. The book carries:</p>${list([...read.manifest
  .values()].filter((item) => item.mediaType === 'video/mp4').map(({ path }) =>
  `${path.slice(read.root.length)} · ${kb(read.files.get(path))}`))}
<p>The QR codes and the PDF links open:</p>${list(clips.map(resourceVideoLink))}
<a download="${RECIPE}.epub" href="${URL.createObjectURL(new Blob([epub],
  { type: 'application/epub+zip' }))}">Download ${RECIPE}.epub</a></article>` });
document.getElementById('pages').before(desk);

// #region pane: the screen edition as HTML, each clip in the browser's own player
// A shadow root keeps the page's styles out, so the pane shows the lines as they were measured.
const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' });
pane.innerHTML = renderToHtml(buildDocument({ markdown, resources },
  applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff',
  resourceImageUrl: imageUrl, // the posters, until a clip plays
  resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here
const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player
pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48;
// #endregion

// ─── 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 ───────────────────────────────────────────────────────────────────────
```

## 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.

```diff
 ];
+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.

```diff
 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](/es/docs/configuration#estilo-de-vídeo).

## Errores frecuentes

- **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.
- **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.
- **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.
- **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.
- **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.
- **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().
- **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.
- **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.
- **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.

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

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 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
- Imágenes: Vídeo 2: el mismo péndulo soltado desde 80°, generado y codificado del mismo modo: Postext Cookbook, CC-BY-4.0
- Imágenes: El primer fotograma del vídeo 1, su portada impresa: Postext Cookbook, CC-BY-4.0
- Imágenes: El primer fotograma del vídeo 2, su portada impresa: Postext Cookbook, CC-BY-4.0
- Tipografías: Source Serif 4 (OFL-1.1), Red Hat Display (OFL-1.1), Red Hat Mono (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 011 · Un solo original, ediciones impresa y de pantalla](https://postext.dev/es/cookbook/print-and-screen-editions.md): Las dos ediciones salen de una configuración: htmlViewer.overrides guarda el diseño oscuro y applyHtmlViewerOverrides lo fusiona antes de componer el HTML. · Nivel 3 (Avanzado) · Revistas y fanzines
- [N.º 028 · Informe de laboratorio: fórmulas, subíndices y curva de pH](https://postext.dev/es/cookbook/lab-report-formulas.md): Un informe de química de cuatro páginas A4 que marca con ~ y ^ las fórmulas y unidades del texto y deja TeX para la reacción y las ecuaciones. · Nivel 3 (Avanzado) · Informes y memorias
- [N.º 060 · Cartilla de lectura con sílabas en chips](https://postext.dev/es/cookbook/reading-primer-syllables.md): Una unidad de cartilla dedicada a la eme, con cada sílaba en un chip del color de su vocal y seis palabras en una tabla, cada una bajo su acuarela. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
