Pular para o conteúdo principal
Receita número 132

Receitas · Capítulo 10 · Saída e integração

Roteiro de laboratório com vídeos na tela e no EPUB

Dois recursos de vídeo com seus próprios MP4: o impresso mostra o primeiro quadro com um QR code; renderToHtml e postext-epub reproduzem os arquivos.

Nesta página
  • Amostra em inglês: ainda sem edição em português
  • Refile 210 × 297 mm
  • 2 colunas, medianiz de 7 mm
  • Source Serif 4 9,4/13,2
  • Red Hat Display
  • Red Hat Mono
  • 2 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 199 ms
  • 239 linhas de código

Em poucas palavras

Um roteiro de aula prática de física com dois vídeos curtos de um pêndulo. No papel, cada vídeo é uma imagem parada com um código para escanear; na tela e no livro digital, o vídeo roda na página.

O que você vai compor

O roteiro 4.2 de Bench Physics, uma apostila A4 para alunos de 16 a 18 anos, sobre o período de um pêndulo. Uma faixa cor de ferrugem traz o título e os campos para nome, turma e data. No pé da primeira página, dois vídeos do mesmo pêndulo, solto a partir de 10° e de 80°, ficam lado a lado. No papel, cada vídeo é o seu primeiro quadro, com um sinal de play e um QR code que abre o MP4. Na edição de tela ao lado das páginas, e no EPUB que o pen gera, os mesmos dois recursos rodam no próprio player de vídeo do leitor. A segunda página traz o procedimento, uma tabela para preencher, os períodos exatos calculados pelo código e quatro questões com caixas para as respostas.

Esta receita responde a

  • Como a edição HTML e o EPUB reproduzem os vídeos do livro, e como oculto o botão de download?
  • Como coloco um vídeo em um livro impresso para que o leitor possa abri-lo a partir da página?

A resposta curta

script.js · linhas 32–61no 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

Tipografia
Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
Materiais
  • pendulum-large-poster.jpg
  • pendulum-large.mp4
  • pendulum-small-poster.jpg
  • pendulum-small.mp4
  • Video 1: a pendulum 1 m long released from 10°, 8 s at 25 frames per second, rendered from an RK4 solution of the pendulum equation (1 ms steps) with Python and encoded with ffmpeg (Postext Cookbook, CC BY 4.0)
  • Video 2: the same pendulum released from 80°, rendered and encoded the same way (Postext Cookbook, CC BY 4.0)
  • The first frame of Video 1, its printed poster (Postext Cookbook, CC BY 4.0)
  • The first frame of Video 2, its printed poster (Postext Cookbook, CC BY 4.0)

Preparo

#1 · Dê a cada vídeo um endereço de produção e um arquivo

O código é a resposta curta logo acima. Um vídeo seu tem dois endereços. video.url é onde o livro publicado vai encontrá-lo: o QR code o imprime e o PDF liga a capa do vídeo a ele, então aqui ele é o endereço do vídeo no jsDelivr, que um celular já consegue abrir hoje. video.fileId identifica o arquivo em si, guardado à parte como um bitmap, para as saídas que podem levá-lo junto ou reproduzi-lo de outro lugar (veja Formato do documento › Vídeos). As opções do player em videoStyle.player valem para todos os vídeos: rodam em loop, começam sem som, carregam inteiros (27 e 61 KB) e não oferecem download, picture-in-picture nem transmissão para outra tela, de modo que os vídeos ficam na folha.

#2 · Diagrame a edição de tela a partir do mesmo original

script.js · linhas 172–180no 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: [] } };

A edição de tela fica na configuração, em htmlViewer.overrides, que o canvas e o PDF ignoram. A 122 dpi, cada medida dada em pontos sai cerca de um quarto maior do que a 96, então o texto de 9,4 pt aparece com 16 px no painel, e as legendas, os boxes e as tabelas crescem junto. A abertura mantém o título e deixa de lado a faixa e os campos, que são coisa do papel.

#3 · Reproduza os vídeos no painel

script.js · linhas 333–340no 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 compõe cada recurso de vídeo como um <video> do HTML5 com as opções do player como atributos: controls, muted, loop, playsinline, preload="auto", disablepictureinpicture, disableremoteplayback e controlslist="nodownload". Este último só esconde o botão. Quem tiver o endereço ainda consegue salvar o arquivo, então trate isso como arrumação da interface, não como proteção. resourceVideoUrl entrega ao renderizador um endereço reproduzível para cada fileId, como resourceImageUrl faz com as capas dos vídeos. Aqui ele é o arquivo publicado; um site que guarda os envios em outro lugar devolve a sua própria URL, ou uma URL de blob com os bytes. Sem ele, o player recorre a video.url, e sem nenhum dos dois o painel mostra a capa.

#4 · Empacote os vídeos num EPUB de layout fixo

script.js · linhas 290–310no código completo
const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
const media = new Map(await Promise.all(clips.map(async ({ video }) =>
  [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }])));
// Every face as bytes (gotcha: epub-embeds-given-fonts): the files loadFonts loaded, latin
// for each face and greek for the text face, limited to the Greek block and listed after
// latin, so a reading system tries it first for θ and π.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
  ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
const fonts = await Promise.all([...faces.map((face) => [face, 'latin']),
  ...faces.filter(({ family }) => family === TEXT)
    .map((face) => [{ ...face, unicodeRange: 'U+0370-03FF' }, 'greek'])]
  .map(async ([face, subset]) => ({ ...face, format: 'woff2', bytes: await bytesOf(
    `https://cdn.jsdelivr.net/npm/@fontsource/${fontsourceId(face.family)}@5/files/`
    + `${fontsourceId(face.family)}-${subset}-${face.weight}-${face.style}.woff2`) })));
const epub = await renderToEpub([doc], { layout: 'fixed', fonts,
  metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG,
    rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run
  // The clips as video/mp4 and the posters as pictures; the book packs both under media/.
  resourceBytes: (fileId) => media.get(fileId)
    ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' },
});

A folha é montada em torno de dois vídeos lado a lado e de caixas vazias para escrever, então o layout fixo a preserva: cada página como foi impressa, com um player onde estava a capa do vídeo. Um EPUB refluível também reproduziria os vídeos, um embaixo do outro na largura do texto. resourceBytes responde video/mp4 para cada vídeo e um JPEG para cada capa, e o gerador os guarda em media/ e images/. O EPUB mantém só os atributos que o seu esquema conhece (controls, autoplay, muted, loop, playsinline, preload), então o botão de download e o resto ficam por conta do sistema de leitura. O arquivo passa no EPUBCheck sem erros nem avisos.

#5 · Componha θ e π no canvas, no PDF e no EPUB

A Source Serif 4 guarda as letras gregas num arquivo próprio do Fontsource, greek, ao lado do latin. loadFonts(FONTS, markdown) encontra letras gregas no texto e carrega esse arquivo para cada fonte da família, e o fontsourceProvider o incorpora ao PDF junto com o arquivo latin em cada fonte que compõe θ ou π, então essas duas letras não exigem código nenhum. O EPUB incorpora só as fontes que o pen lhe entrega, por isso o passo 4 lista os arquivos greek da fonte do texto depois dos latin, cada um limitado ao bloco grego pelo seu unicodeRange.

#6 · Calcule a tabela no código

script.js · linhas 65–86no 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]);

O período exato exige a integral elíptica completa K, e a média aritmético-geométrica a fornece em poucos passos: T = T~0~ / AGM(1, cos ½θ~0~). A tabela imprime três casas decimais com a vírgula decimal do espanhol, e as linhas de 10° e 80° batem com os vídeos: 2,010 s e 2,282 s, 0,2 % e 13,8 % mais longos que T~0~ = 2,006 s. Em 8 s, isso dá pouco menos de quatro oscilações completas no primeiro vídeo e três e meia no segundo.

A receita 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.19.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 = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'pendulum-lab-video-players';

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

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

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

// #region opener: a rust band with the sheet's number, its title and the fields to fill in
const words = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // 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`---
Amostra em Markdown · 86 linhas · content.en.mdtitle: "Bench Physics" subtitle: "A lab workbook for ages 16 to 18" --- # The period of a pendulum {kicker="Unit 4 · Oscillations · Lab sheet 4.2" lead="Two pendulums of the same length, released from 10° and from 80°. Time both on video and find out whether the period depends on the size of the swing."} :::callout{type="aim" title="Aim"} To measure the period of a pendulum 1.00 m long from two video clips, and to find out how far the small-angle formula can be trusted. **You need** the two clips, a phone or a screen to play them, and a calculator. ::: ## The two clips :ref{id="small"} and :ref{id="large"} were rendered from a numerical solution of the pendulum’s equation of motion, θ″ = −(*g*/*L*) sin θ, worked out in steps of one millisecond for a pendulum 1.00 m long. Each clip lasts 8.00 s at 25 frames per second, so one frame is 0.04 s. There is no air resistance in the model, so the swings never die away. The still printed for each clip is its first frame, and the code in its corner opens the clip itself. Scan it with a phone, or play the clips in the screen edition of this workbook. Both clips loop, start with the sound off and have a speed menu: slow them to half speed when you count. ## Background A simple pendulum is a small heavy bob on a light string that does not stretch. Pulled aside and let go, it swings to and fro through its lowest point. The **period**, *T*, is the time it takes to go out and come back to where it started. Galileo noticed that a pendulum keeps the same period as its swings die away, and clockmakers relied on that for three hundred years. For small swings the period depends only on the length *L* and on the gravitational field strength *g*: :::paragraphs{style="formula"} *T*~0~ = 2π (*L*/*g*)^1/2^ ::: With *L* = 1.00 m and *g* = 9.81 m s^−2^, the formula gives *T*~0~ = 2.006 s. Its derivation replaces sin θ by θ in radians, which is within 1 % of the truth only up to about 14°. Past that, the pull back towards the middle grows more slowly than the angle, the bob lingers near the turning points and every swing takes a little longer. With the amplitude θ~0~, the angle the bob is released from, and *K*, the complete elliptic integral of the first kind, the exact period is :::paragraphs{style="formula"} *T* = 4 (*L*/*g*)^1/2^ *K*(sin ½θ~0~) ::: ## Method :::callout{type="method"} 1. Play the first clip from the start. The bob is let go from rest in the first frame. 2. Count the complete swings, out and back to the release point, until the clip ends at 8.00 s. Estimate the last one to a quarter of a swing. 3. Divide 8.00 s by the number of swings to find the period. 4. Repeat for the second clip and fill in the table below. ::: ## Results ::resource{id="data"} The values in :ref{id="periods"} come from the exact formula, worked out to three decimals. Compare your periods with the rows for 10° and 80°. ::resource{id="periods"} ## Questions :::callout{type="sheet"} 1. Which clip has the longer period, and by what percentage? :::callout{type="answer"} Answer :::space{lines=3} ::: 2. The bob in the second clip travels a much longer arc. Why does its period not grow in the same proportion? :::callout{type="answer"} Answer :::space{lines=4} ::: 3. The first correction to the small-angle formula gives *T* = *T*~0~ (1 + θ~0~^2^/16), with θ~0~ in radians. Work it out at 80° and compare it with the table. Is one term enough? :::callout{type="answer"} Answer :::space{lines=4} ::: 4. A long-case clock swings 4° either side of the vertical. Would its maker need the exact formula? :::callout{type="answer"} Answer :::space{lines=3} ::: ::: :::paragraphs{style="colophon"} Bench Physics, lab sheet 4.2 · Set in Source Serif 4, Red Hat Display and Red Hat Mono (SIL Open Font License) · Text and clips: Postext Cookbook, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook const resources = [...clips, data, periods]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Source Serif 4': ['400', '400i', '700', '700i'], 'Red Hat Display': ['700', '800'], 'Red Hat Mono': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // θ and π: markdown has Greek letters, so the kit loads Source Serif 4's greek file too // and the PDF provider embeds it beside the latin one (gotcha: latin-subset). await Promise.all([loadFonts(FONTS, markdown), ...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: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #region epub: a fixed-layout EPUB that carries both MP4 files and plays them on the page const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()); const media = new Map(await Promise.all(clips.map(async ({ video }) => [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }]))); // Every face as bytes (gotcha: epub-embeds-given-fonts): the files loadFonts loaded, latin // for each face and greek for the text face, limited to the Greek block and listed after // latin, so a reading system tries it first for θ and π. const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); const fonts = await Promise.all([...faces.map((face) => [face, 'latin']), ...faces.filter(({ family }) => family === TEXT) .map((face) => [{ ...face, unicodeRange: 'U+0370-03FF' }, 'greek'])] .map(async ([face, subset]) => ({ ...face, format: 'woff2', bytes: await bytesOf( `https://cdn.jsdelivr.net/npm/@fontsource/${fontsourceId(face.family)}@5/files/` + `${fontsourceId(face.family)}-${subset}-${face.weight}-${face.style}.woff2`) }))); const epub = await renderToEpub([doc], { layout: 'fixed', fonts, metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG, rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run // The clips as video/mp4 and the posters as pictures; the book packs both under media/. resourceBytes: (fileId) => media.get(fileId) ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' }, }); // #endregion const [read, kb] = [readEpub(epub), (bytes) => `${Math.round(bytes.length / 1024)} KB`]; const list = (lines) => `<ul>${lines.map((line) => `<li>${line}</li>`).join('')}</ul>`; const desk = Object.assign(document.createElement('section'), { id: 'editions', innerHTML: ` <style>#editions{display:flex;flex-wrap:wrap;gap:28px;justify-content:center;align-items:start; padding:28px 16px 0;color:#d9d5cc}#editions figure{margin:0}#editions figcaption{margin-top:16px; color:#8b8f97}#screen{width:${PANE_W}px;height:${PANE_H}px;overflow:auto;border-radius:12px; box-shadow:0 0 0 8px #1d1f24}#editions article{width:min(380px,92vw);padding:4px 18px; border-radius:8px;background:#15181d;overflow-wrap:anywhere}#editions a{color:#d8a21a}</style> <figure><div id="screen" role="region" tabindex="0"></div><figcaption>${t({ en: 'Screen' + ' edition', es: 'Edición de pantalla' })} · HTML · ${PANE_W} × ${PANE_H} px</figcaption> </figure><article><h3>EPUB 3 · ${read.layout}</h3> <p>${read.spine.length} pages · ${kb(epub)}. The book carries:</p>${list([...read.manifest .values()].filter((item) => item.mediaType === 'video/mp4').map(({ path }) => `${path.slice(read.root.length)} · ${kb(read.files.get(path))}`))} <p>The QR codes and the PDF links open:</p>${list(clips.map(resourceVideoLink))} <a download="${RECIPE}.epub" href="${URL.createObjectURL(new Blob([epub], { type: 'application/epub+zip' }))}">Download ${RECIPE}.epub</a></article>` }); document.getElementById('pages').before(desk); // #region pane: the screen edition as HTML, each clip in the browser's own player // A shadow root keeps the page's styles out, so the pane shows the lines as they were measured. const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' }); pane.innerHTML = renderToHtml(buildDocument({ markdown, resources }, applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff', resourceImageUrl: imageUrl, // the posters, until a clip plays resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48; // #endregion
Kit · core, fonts, viewer, pdf, images: igual em todas as receitas · 316 linhas// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ 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 v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images 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 ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Faça um vídeo começar sozinho

O player de cada vídeo se sobrepõe a videoStyle.player. A reprodução automática sempre começa sem som, como os navegadores exigem.

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

#Mostre na tela a capa impressa

Com html: 'poster', a edição HTML compõe a capa do vídeo com o sinal de play e o QR code, com link para o endereço de produção, no lugar do player.

 const videoStyle = {
+  html: 'poster',

#Use um vídeo do YouTube ou do Vimeo

source: 'youtube' com o endereço do vídeo incorpora o player do YouTube a partir de youtube-nocookie.com (privacy vem ligado por padrão). Esse player aceita menos opções: controles, tela cheia, reprodução automática, sem som e loop; o do Vimeo acrescenta o menu de velocidade e o picture-in-picture, e nenhum dos dois oferece download. Um EPUB não pode incorporar o player de uma página web, então lá o vídeo é a sua capa com link para ele. As opções e o alcance de cada uma estão em Configuração › Estilo de vídeo.

Erros comuns

Erro comum

renderToEpub incorpora só as fontes que você passa

As fontes carregadas pelo navegador não são arquivos que o postext-epub consiga ler: passe em options.fonts os bytes de cada fonte que as páginas usam (os mesmos arquivos woff2 do Fontsource que a página carregou). Uma fonte que ficar de fora é informada como missingFont, e o sistema de leitura compõe esse texto com uma fonte própria, que em um layout fixo já não cabe nas linhas com que a página foi composta. Incorpore só fontes cuja licença permita. Exportação para EPUB →

Erro comum

Os arquivos do Fontsource cobrem letras, não símbolos

O kit incorpora o arquivo latin do Fontsource de cada fonte e, quando o texto usa esses caracteres, o arquivo latin-ext para č, ł, † e o arquivo greek para α, χ (em uma família que o tenha: Lora e Gelasio não têm), na tela e no PDF. Símbolos como →, ≈, ✓ e ★ não estão em nenhum desses arquivos e somem no PDF: desenhe-os, componha-os como matemática ou escolha uma fonte cujos arquivos os tenham. Fontes incorporadas ao PDF →

Erro comum

O PDF pede todos os pesos e estilos de cada família

renderToPdf pede ao provedor de fontes o negrito, o itálico e o negrito itálico de cada família que um bloco poderia usar, mesmo os que nunca são impressos, e uma única recusa interrompe a exportação. O provedor deve se ajustar ao peso mais próximo que a família oferece e voltar ao romano quando não houver itálico. Fontes incorporadas ao PDF →

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

Erro comum

Carregue todas as fontes antes do layout

O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →

Erro comum

A configuração fica em cache pela identidade: crie um objeto novo

O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →

Erro comum

Traduza Figura e Tabela com defaultResourceTypes(locale)

O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →

Erro comum

Só 8 idiomas têm hifenização, com o código exato

A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →

Erro comum

O excesso de texto de design é 'ellipsis-end' por padrão

Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →

Os MP4 estão em H.264. Chrome, Edge, Safari e Firefox os reproduzem, assim como o Chrome for Testing que a captura usa. Um Chromium compilado sem os codecs proprietários, o padrão do projeto de código aberto, não reproduz; codifique os vídeos em WebM (format: 'webm') se isso importar para os seus leitores.

Créditos

Texto
Texto original, CC BY 4.0
Imagens
  • Video 1: a pendulum 1 m long released from 10°, 8 s at 25 frames per second, rendered from an RK4 solution of the pendulum equation (1 ms steps) with Python and encoded with ffmpeg · Postext Cookbook · CC BY 4.0
  • Video 2: the same pendulum released from 80°, rendered and encoded the same way · Postext Cookbook · CC BY 4.0
  • The first frame of Video 1, its printed poster · Postext Cookbook · CC BY 4.0
  • The first frame of Video 2, its printed poster · Postext Cookbook · CC BY 4.0
Fontes
Source Serif 4 (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
SandboxPDF