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

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

Editor ao vivo com a diagramação num Web Worker

Markdown ao lado de uma página de bolso: um worker criado a partir de um blob compõe com as próprias fontes, e cada tecla cancela a composição em andamento.

Nesta página
Nível
Avançado
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.4.1
Licença
Atualizada em 26 de set. de 2026
Código MIT · Texto MIT
  • Amostra em inglês: ainda sem edição em português
  • Refile 110 × 147 mm
  • 1 coluna
  • Baskervville 9,5/13
  • Baskervville SC
  • Cinzel
  • 9 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 104 ms
  • 248 linhas de código

Em poucas palavras

Um editor ao vivo para uma pequena edição de bolso de A Máquina do Tempo. A cada tecla, a página é diagramada de novo em segundo plano, e a tela continua respondendo.

O que você vai compor

Você compõe o capítulo I de A Máquina do Tempo, de H. G. Wells, em inglês, como uma edição de bolso de 110 × 147 mm, com o Markdown ao lado. A folha de rosto traz a fotografia de um mostrador de latão com quatro mostradores pequenos, gerada com modelos de difusão, sobre um campo cor de sangue de boi desenhado em código, acima do título em Cinzel. O texto do capítulo começa na nona linha, sob um numeral romano e um fio de latão, e corre em Baskervville 9,5/13 pt sob cabeços em versaletes. Cada tecla compõe o capítulo inteiro de novo num Web Worker, e o canvas à direita vai para a página onde está o cursor. Sob o editor, um ponteiro que a thread principal gira a cada quadro para enquanto essa thread está ocupada, e a linha de status mostra o tempo da composição, o quadro mais longo e quantas composições uma tecla mais recente cancelou. Um menu no canto superior direito passa a composição para a thread principal, para comparar.

Esta receita responde a

  • Como evito que a página trave enquanto um livro longo é composto (Web Worker, cancelamento)?
  • Por que as minhas quebras de linha mudam ou as palavras se sobrepõem no PDF, e como carrego as fontes corretamente?
  • Posso gerar páginas ou PDFs em um servidor ou pela linha de comando (Node)?

A resposta curta

script.js · linhas 137–165no código completo
async function startLayoutWorker(faces) {
  // In 1.4.1, createLayoutWorker() on its own starts esm.sh's worker file, which the browser
  // refuses to run from another origin; a same-origin blob that imports it is allowed.
  // An import map does not reach the worker: if the page pins postext@x.y.z, pin this URL too.
  const entry = new Blob([`import 'https://esm.sh/postext/worker/entry';`],
    { type: 'text/javascript' });
  const layout = createLayoutWorker({
    worker: new Worker(URL.createObjectURL(entry), { type: 'module' }) });
  // The worker measures with its own FontFaceSet, not the page's. Without the bytes of every
  // face it measures in a fallback font, and 1.4.1 raises no error. Weights are strings.
  const payloads = await Promise.all(faces.map(async ({ family, weight, style, url }) => {
    // Check the status: a 404 page sent as a font only logs a warning inside the worker.
    const response = await fetch(url);
    if (!response.ok) throw new Error(`${family} ${weight} ${style}: HTTP ${response.status}`);
    return { family, weight, style, buffer: await response.arrayBuffer() };
  }));
  await layout.registerFonts(payloads); // the buffers move to the worker, not copied
  let inFlight = null;
  return async function typeset(content) {
    inFlight?.abort(); // cancel the build that the previous keystroke asked for
    const build = (inFlight = new AbortController());
    try {
      return await layout.build(content, config(), { signal: build.signal });
    } catch (error) {
      if (error.name === 'AbortError') return null; // superseded: a newer build is on its way
      throw error;
    }
  };
}

Ingredientes

Tipografia
Baskervville, Baskervville SC, Cinzel (SIL OFL 1.1)
Materiais
  • dial-1120.jpg
  • The title page’s brass dial, a photograph (Generated With Diffusion Models, original)
  • The title page’s oxblood cloth and gilt frame, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Preparo

#1 · Iniciar o worker a partir de um blob

O código deste passo é a resposta curta acima. Na 1.4.1, createLayoutWorker() chamado sem opções inicia o arquivo do worker que fica ao lado do módulo. Quando o módulo vem do esm.sh, esse arquivo está na origem do esm.sh, e o construtor Worker lança um SecurityError. Uma URL de blob criada pela página tem a origem da página, então a resposta curta inicia um worker de módulo a partir de um blob de uma linha que importa postext/worker/entry do esm.sh, e passa esse worker para createLayoutWorker({ worker }). O build() do objeto retornado aceita o mesmo conteúdo e a mesma configuração que buildDocument() e resolve com o mesmo tipo de documento (Rodar a diagramação num Web Worker).

#2 · Carregar cada fonte duas vezes

script.js · linhas 315–327no código completo
const FONTS = { // every face the pages use: the page loads them, and so must the worker
  Baskervville: ['400', '400i'], // text, folios, subtitle, colophon
  'Baskervville SC': ['500'], // running heads, the author
  Cinzel: ['700'], // title, numeral, chapter title
};
// The worker gets the same Fontsource files the page loads: identical metrics on both threads.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const [id, weight, style] = [family.toLowerCase().replace(/ /g, '-'), parseInt(spec, 10),
    spec.endsWith('i') ? 'italic' : 'normal'];
  const file = `${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
  return { family, weight: String(weight), style,
    url: `https://cdn.jsdelivr.net/npm/@fontsource/${file}` };
}));

O worker tem o seu próprio FontFaceSet e não enxerga as fontes que a página carregou, então a resposta curta baixa os mesmos arquivos do Fontsource e passa os bytes para registerFonts(), com cada peso como string ('400'). Sem essa chamada, o worker da 1.4.1 mede com uma fonte substituta e não avisa: compõe este capítulo em 175 linhas em vez de 190, e a primeira linha termina em “him)” em vez de “of”. Um arquivo de fonte que não baixa também passa sem erro. Se o worker recebe uma página 404 no lugar da Baskervville redonda, registerFonts() resolve do mesmo jeito e o capítulo sai em 186 linhas, com um aviso só no console do worker. Por isso a resposta curta confere o status de cada resposta antes de enviar qualquer byte, e lança um erro com a fonte e o status HTTP. A página também carrega as mesmas fontes com loadFonts(), porque o canvas pinta com as fontes da página.

#3 · Compor a cada tecla, pintar na thread principal

script.js · linhas 350–396no código completo
let [doc, shown, builds, cancelled] = [null, 0, 0, 0];
// For the comparison, the main thread keeps a measurement cache as the worker does, so its
// pages match the worker's: in 1.4.1 a build with a cache can break lines differently.
const mainCache = createMeasurementCache();
function paint(n = shown) { // the canvas is sized to its box, in device pixels
  shown = Math.max(0, Math.min(doc.pages.length - 1, n));
  const vdtPage = doc.pages[shown]; // a laid-out page, not the page config above
  const height = ($('proof').clientHeight || 640) * Math.min(devicePixelRatio || 1, 2);
  renderPageToCanvas(vdtPage, doc, $('proof'), { scale: height / vdtPage.height });
  $('proof').setAttribute('aria-label', `Page ${vdtPage.pageLabel}`);
  $('folio').value = `${shown + 1} / ${doc.pages.length}`;
}
function follow() { // turn to the page that holds the caret: every block keeps its source offset
  const caret = $('source').selectionStart;
  paint(doc.pages.findLastIndex((vdtPage) => vdtPage.columns.some((column) =>
    column.blocks.some((block) => block.sourceStart <= caret))));
}
async function refresh() {
  const [ticket, onMain, started] = [++builds, $('thread').value === 'main', performance.now()];
  frames.worst = 0;
  const content = { markdown: $('source').value, resources };
  const next = onMain ? buildDocument(content, config(), mainCache) : await typeset(content);
  await new Promise(requestAnimationFrame); // the first frame after the build shows any stall
  if (!next || ticket !== builds) { cancelled++; return; } // a newer build has been asked for
  doc = next;
  if (document.activeElement === $('source')) follow(); else paint();
  showPages(doc, { title: 'The Time Machine · chapter I, set in a Web Worker' });
  $('clock').value = `${onMain ? 'Main thread' : 'Worker'} · ${doc.pages.length} pages in `
    + `${Math.round(performance.now() - started)} ms · longest frame ${Math.round(frames.worst)}`
    + ` ms · ${builds} ${builds === 1 ? 'build' : 'builds'}, ${cancelled} cancelled`;
}
$('source').addEventListener('input', refresh);
for (const type of ['click', 'keyup']) $('source').addEventListener(type, () => doc && follow());
$('thread').addEventListener('change', refresh);
$('prev').addEventListener('click', () => paint(shown - 1));
$('next').addEventListener('click', () => paint(shown + 1));
// A dial the main thread turns on every frame: it stops while that thread is busy.
const frames = { last: 0, worst: 0 };
requestAnimationFrame(function turn() { // the clock, not the frame's timestamp: a late frame
  const now = performance.now(); // keeps the time it was due, which hides the stall
  frames.worst = Math.max(frames.worst, now - (frames.last || now));
  frames.last = now;
  const deg = ((now * 0.06) % 360).toFixed(1); // a turn in 6 s
  $('hand').setAttribute('transform', `rotate(${deg})`);
  requestAnimationFrame(turn);
});
await refresh();

typeset() aborta a composição em andamento antes de pedir a seguinte. A promise da composição abortada é rejeitada na hora com um AbortError, que typeset() transforma em null. No worker, uma composição que ainda espera na fila é descartada de imediato, e uma em andamento para no fim da passada de posicionamento atual, porque a composição só verifica o cancelamento entre uma passada e outra. refresh() numera as composições e pinta só a mais recente, então uma composição do worker que chega atrasada não pode pintar por cima de uma da thread principal. O documento volta para a thread principal, onde renderPageToCanvas() o pinta com as imagens registradas ali. O worker só precisa do tamanho de cada imagem, que vem na entrada do recurso (1100 × 840). Cada bloco guarda a sua posição no Markdown (sourceStart), e follow() mostra a última página com um bloco que começa no cursor ou antes dele.

#4 · Uma folha de rosto que é um estilo de título

script.js · linhas 56–78no código completo
// # The Time Machine {style="title"}: numbered false, so the Introduction is still chapter I.
const PLATE = TRIM.width * (840 / 1100); // mm: the plate's depth at full width (84 mm)
const DIAL = { x: 20, y: 7, size: 70 }; // mm: the dial's photograph, centred on the plate
const titlePage = {
  id: 'title', numbered: false,
  span: 'page', // kept in the column, the design is clipped to it: the plate's top, the author
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'plate', resourceId: 'plate',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'image', id: 'dial', resourceId: 'dial', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, offset: { x: mm(DIAL.x), y: mm(DIAL.y) },
      size: { width: mm(DIAL.size), height: mm(DIAL.size) } } },
    text('title', '{titleText}', DISPLAY, 28, { fontWeight: 700, lineHeight: 1.04,
      overflow: 'wrap' }, // two lines, not one and '…' (gotcha: overflow-ellipsis-default)
    onPage(PLATE + 11)),
    text('subtitle', '{subtitle}', TEXT, 12, { italic: true, color: col('oxblood') },
      onPage(PLATE + 34.5)),
    brassRule('rule', onPage(PLATE + 43), 12),
    text('author', '{author}', LABEL, 10, { fontWeight: 500, letterSpacing: pt(2) },
      onPage(PLATE + 46.5)),
  ] } },
};

A linha # The Time Machine {style="title"} compõe a página 1 no estilo de título title, cujos header e footer vazios deixam a página sem cabeços nem fólio. numbered: false deixa o título fora da contagem de capítulos, então Introduction continua sendo o capítulo I. A ilustração é ancorada no canto superior esquerdo da sangria, e o nome do autor fica abaixo do pé da coluna; span: 'page' deixa o design pintar nos dois lugares. Sem isso, a 1.4.1 recorta o design na coluna, a ilustração perde os 15 mm de cima e o nome do autor não é pintado.

#5 · Cabeços conforme o papel da página

script.js · linhas 82–98no código completo
// pages: 'body' keeps the heads off the title page and the opener; parity puts the book's
// title on the verso and the chapter on the recto, folios on the outer edge.
const HEAD_Y = 8.2; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontSize: pt(8), color: col('oxblood'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const smallCaps = { fontFamily: LABEL, fontWeight: 500, letterSpacing: pt(1.2) };
const folio = { fontFamily: TEXT, color: col('ink') }; // the heads' size: the same baseline
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-chapter', '{chapterTitle}', 'odd', 'top', SHIFT, smallCaps),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...text('drop-folio', '{pageNumber}', TEXT, 8,
  { color: col('muted') }, inColumn(6.5)), pages: 'opener' }] };

pages: 'body' tira os cabeços da folha de rosto e da abertura, e parity põe o título do livro nas páginas pares e o do capítulo nas ímpares, com os fólios na margem externa. A abertura recebe um fólio no pé, de um elemento do rodapé com pages: 'opener'. Fólios e cabeços têm todos 8 pt e ficam 8,2 mm abaixo da borda superior, então os algarismos do fólio se apoiam na linha de base dos cabeços.

#6 · A abertura do capítulo

script.js · linhas 102–113no código completo
const chapter = { level: 1, numberingTemplate: '{1:I}', // {number} prints 'I'
  breakBefore: { enabled: true, parity: 'any' }, // restated (gotcha: headings-drop-h1-break)
  marginTop: pt(0), marginBottom: pt(0),
  advancedDesign: { enabled: true, minHeight: line(8), slot: { elements: [
    text('numeral', '{number}', DISPLAY, 24, { fontWeight: 700, color: col('oxblood') },
      inColumn(5)),
    brassRule('rule', inColumn(18.5), 10),
    text('chapter', '{titleText}', DISPLAY, 12, { fontWeight: 700, letterSpacing: pt(1.8),
      textTransform: 'uppercase', overflow: 'wrap' }, inColumn(22.5)), // a longer title wraps
  ] } } };
const colophon = { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  textAlign: 'center', firstLineIndent: pt(0) };

O {number} do design imprime o número do capítulo na forma que numberingTemplate: '{1:I}' lhe dá, um I romano. minHeight reserva pelo menos oito linhas, então o texto começa na nona enquanto o título ocupar uma ou duas, e mais abaixo quando um título mais longo precisar de mais espaço. breakBefore é repetido porque um objeto headings derruba a quebra padrão do H1; parity: 'any' deixa o capítulo abrir na página 2, de frente para a segunda página dele.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 056 · Live editor with layout in a Web Worker ═══════════
// https://postext.dev/en/cookbook/web-worker-live-editor
// Code: MIT · Text: H. G. Wells, The Time Machine, 1895 (PD, Gutenberg #35) · Dial: generated
// Fonts: Baskervville, Baskervville SC, Cinzel (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, createMeasurementCache, renderPageToCanvas, clearMeasurementCache,
  registerResourceImage,
} from 'https://esm.sh/postext';
import { createLayoutWorker } from 'https://esm.sh/postext/worker';

const LANG = 'en'; // @lang: the language of the sample document (this recipe is English only)
const RECIPE = 'web-worker-live-editor';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231f1a', // the text: a warm near-black
  oxblood: '#7a1f1f', // the accent: running heads, the numeral, the subtitle, the plate's cloth
  brass: '#a88a4a', // rules (never text: 2.7:1 on the paper)
  gilt: '#d8bd7c', // the plate's frame, on the oxblood
  muted: '#6b5d4b', // the drop folio and the colophon (5.3:1 on the paper)
  paper: '#f2ead8', // a cream pocket-book paper
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color'. The editor takes any Markdown, and without this
  // entry a list typed into it gets the default blue markers.
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const [TEXT, LABEL, DISPLAY] = ['Baskervville', 'Baskervville SC', 'Cinzel'];

// The page: 110 × 147 mm, a text block of 25 whole lines.
const TRIM = { width: 110, height: 147 }; // mm: a Victorian pocket size, close to A6
const [BODY, LEAD, LINES] = [9.5, 13, 25]; // pt, pt, lines: the text block is LINES leads deep
const [TOP, INNER, OUTER] = [15, 11, 9]; // mm; mirrored, so INNER is the spine side
const MM_PER_PT = 25.4 / 72;
const MEASURE = TRIM.width - INNER - OUTER; // 90 mm: about 60 characters of Baskervville
const line = (n) => pt(n * LEAD); // n grid lines
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};

const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // centred
const inColumn = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const text = (id, content, fontFamily, fontSize, style, placement) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(fontSize), color: col('ink'), align: 'center', ...style,
  placement: { ...placement, size: { width: mm(MEASURE) } } });
const brassRule = (id, y, width) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(0.75), color: col('brass'), placement: { ...y, size: { width: mm(width) } } });

// #region title: page 1 is a heading style of its own: the plate, the title and no heads
// # The Time Machine {style="title"}: numbered false, so the Introduction is still chapter I.
const PLATE = TRIM.width * (840 / 1100); // mm: the plate's depth at full width (84 mm)
const DIAL = { x: 20, y: 7, size: 70 }; // mm: the dial's photograph, centred on the plate
const titlePage = {
  id: 'title', numbered: false,
  span: 'page', // kept in the column, the design is clipped to it: the plate's top, the author
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'plate', resourceId: 'plate',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'image', id: 'dial', resourceId: 'dial', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, offset: { x: mm(DIAL.x), y: mm(DIAL.y) },
      size: { width: mm(DIAL.size), height: mm(DIAL.size) } } },
    text('title', '{titleText}', DISPLAY, 28, { fontWeight: 700, lineHeight: 1.04,
      overflow: 'wrap' }, // two lines, not one and '…' (gotcha: overflow-ellipsis-default)
    onPage(PLATE + 11)),
    text('subtitle', '{subtitle}', TEXT, 12, { italic: true, color: col('oxblood') },
      onPage(PLATE + 34.5)),
    brassRule('rule', onPage(PLATE + 43), 12),
    text('author', '{author}', LABEL, 10, { fontWeight: 500, letterSpacing: pt(2) },
      onPage(PLATE + 46.5)),
  ] } },
};
// #endregion

// #region heads: running heads on body pages only, a drop folio on the opener
// pages: 'body' keeps the heads off the title page and the opener; parity puts the book's
// title on the verso and the chapter on the recto, folios on the outer edge.
const HEAD_Y = 8.2; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontSize: pt(8), color: col('oxblood'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const smallCaps = { fontFamily: LABEL, fontWeight: 500, letterSpacing: pt(1.2) };
const folio = { fontFamily: TEXT, color: col('ink') }; // the heads' size: the same baseline
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-chapter', '{chapterTitle}', 'odd', 'top', SHIFT, smallCaps),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...text('drop-folio', '{pageNumber}', TEXT, 8,
  { color: col('muted') }, inColumn(6.5)), pages: 'opener' }] };
// #endregion

// #region opener: the chapter sinks eight lines under its roman numeral and a brass rule
const chapter = { level: 1, numberingTemplate: '{1:I}', // {number} prints 'I'
  breakBefore: { enabled: true, parity: 'any' }, // restated (gotcha: headings-drop-h1-break)
  marginTop: pt(0), marginBottom: pt(0),
  advancedDesign: { enabled: true, minHeight: line(8), slot: { elements: [
    text('numeral', '{number}', DISPLAY, 24, { fontWeight: 700, color: col('oxblood') },
      inColumn(5)),
    brassRule('rule', inColumn(18.5), 10),
    text('chapter', '{titleText}', DISPLAY, 12, { fontWeight: 700, letterSpacing: pt(1.8),
      textTransform: 'uppercase', overflow: 'wrap' }, inColumn(22.5)), // a longer title wraps
  ] } } };
const colophon = { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  textAlign: 'center', firstLineIndent: pt(0) };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page,
  layout: { layoutType: 'single' }, // one column: the default is two
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false,
    // Copy-fitted: at these spacings chapter I sets 25 lines on every full page, with no
    // hyphen inside a hyphenated word ('af-/ter-dinner') on the pages the Cookbook shows.
    minWordSpacing: 0.66, maxWordSpacing: 1.9,
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('ink'), levels: [chapter] },
  headingStyles: [titlePage],
  paragraphStyles: [colophon],
  header,
  footer,
});

// #region answer: layout in a module worker started from a blob, with its own fonts
async function startLayoutWorker(faces) {
  // In 1.4.1, createLayoutWorker() on its own starts esm.sh's worker file, which the browser
  // refuses to run from another origin; a same-origin blob that imports it is allowed.
  // An import map does not reach the worker: if the page pins postext@x.y.z, pin this URL too.
  const entry = new Blob([`import 'https://esm.sh/postext/worker/entry';`],
    { type: 'text/javascript' });
  const layout = createLayoutWorker({
    worker: new Worker(URL.createObjectURL(entry), { type: 'module' }) });
  // The worker measures with its own FontFaceSet, not the page's. Without the bytes of every
  // face it measures in a fallback font, and 1.4.1 raises no error. Weights are strings.
  const payloads = await Promise.all(faces.map(async ({ family, weight, style, url }) => {
    // Check the status: a 404 page sent as a font only logs a warning inside the worker.
    const response = await fetch(url);
    if (!response.ok) throw new Error(`${family} ${weight} ${style}: HTTP ${response.status}`);
    return { family, weight, style, buffer: await response.arrayBuffer() };
  }));
  await layout.registerFonts(payloads); // the buffers move to the worker, not copied
  let inFlight = null;
  return async function typeset(content) {
    inFlight?.abort(); // cancel the build that the previous keystroke asked for
    const build = (inFlight = new AbortController());
    try {
      return await layout.build(content, config(), { signal: build.signal });
    } catch (error) {
      if (error.name === 'AbortError') return null; // superseded: a newer build is on its way
      throw error;
    }
  };
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 118 linhas · content.en.mdtitle: "The Time Machine" subtitle: "An Invention" author: "H. G. Wells" --- # The Time Machine {style="title"} # Introduction The Time Traveller (for so it will be convenient to speak of him) was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated. The fire burnt brightly, and the soft radiance of the incandescent lights in the lilies of silver caught the bubbles that flashed and passed in our glasses. Our chairs, being his patents, embraced and caressed us rather than submitted to be sat upon, and there was that luxurious after-dinner atmosphere, when thought runs gracefully free of the trammels of precision. And he put it to us in this way—marking the points with a lean forefinger—as we sat and lazily admired his earnestness over this new paradox (as we thought it) and his fecundity. “You must follow me carefully. I shall have to controvert one or two ideas that are almost universally accepted. The geometry, for instance, they taught you at school is founded on a misconception.” “Is not that rather a large thing to expect us to begin upon?” said Filby, an argumentative person with red hair. “I do not mean to ask you to accept anything without reasonable ground for it. You will soon admit as much as I need from you. You know of course that a mathematical line, a line of thickness *nil*, has no real existence. They taught you that? Neither has a mathematical plane. These things are mere abstractions.” “That is all right,” said the Psychologist. “Nor, having only length, breadth, and thickness, can a cube have a real existence.” “There I object,” said Filby. “Of course a solid body may exist. All real things—” “So most people think. But wait a moment. Can an *instantaneous* cube exist?” “Don’t follow you,” said Filby. “Can a cube that does not last for any time at all, have a real existence?” Filby became pensive. “Clearly,” the Time Traveller proceeded, “any real body must have extension in *four* directions: it must have Length, Breadth, Thickness, and—Duration. But through a natural infirmity of the flesh, which I will explain to you in a moment, we incline to overlook this fact. There are really four dimensions, three which we call the three planes of Space, and a fourth, Time. There is, however, a tendency to draw an unreal distinction between the former three dimensions and the latter, because it happens that our consciousness moves intermittently in one direction along the latter from the beginning to the end of our lives.” “That,” said a very young man, making spasmodic efforts to relight his cigar over the lamp; “that … very clear indeed.” “Now, it is very remarkable that this is so extensively overlooked,” continued the Time Traveller, with a slight accession of cheerfulness. “Really this is what is meant by the Fourth Dimension, though some people who talk about the Fourth Dimension do not know they mean it. It is only another way of looking at Time. *There is no difference between Time and any of the three dimensions of Space except that our consciousness moves along it*. But some foolish people have got hold of the wrong side of that idea. You have all heard what they have to say about this Fourth Dimension?” “*I* have not,” said the Provincial Mayor. “It is simply this. That Space, as our mathematicians have it, is spoken of as having three dimensions, which one may call Length, Breadth, and Thickness, and is always definable by reference to three planes, each at right angles to the others. But some philosophical people have been asking why *three* dimensions particularly—why not another direction at right angles to the other three?—and have even tried to construct a Four-Dimensional geometry. Professor Simon Newcomb was expounding this to the New York Mathematical Society only a month or so ago. You know how on a flat surface, which has only two dimensions, we can represent a figure of a three-dimensional solid, and similarly they think that by models of three dimensions they could represent one of four—if they could master the perspective of the thing. See?” “I think so,” murmured the Provincial Mayor; and, knitting his brows, he lapsed into an introspective state, his lips moving as one who repeats mystic words. “Yes, I think I see it now,” he said after some time, brightening in a quite transitory manner. “Well, I do not mind telling you I have been at work upon this geometry of Four Dimensions for some time. Some of my results are curious. For instance, here is a portrait of a man at eight years old, another at fifteen, another at seventeen, another at twenty-three, and so on. All these are evidently sections, as it were, Three-Dimensional representations of his Four-Dimensioned being, which is a fixed and unalterable thing.” “Scientific people,” proceeded the Time Traveller, after the pause required for the proper assimilation of this, “know very well that Time is only a kind of Space. Here is a popular scientific diagram, a weather record. This line I trace with my finger shows the movement of the barometer. Yesterday it was so high, yesterday night it fell, then this morning it rose again, and so gently upward to here. Surely the mercury did not trace this line in any of the dimensions of Space generally recognised? But certainly it traced such a line, and that line, therefore, we must conclude, was along the Time-Dimension.” “But,” said the Medical Man, staring hard at a coal in the fire, “if Time is really only a fourth dimension of Space, why is it, and why has it always been, regarded as something different? And why cannot we move about in Time as we move about in the other dimensions of Space?” The Time Traveller smiled. “Are you so sure we can move freely in Space? Right and left we can go, backward and forward freely enough, and men always have done so. I admit we move freely in two dimensions. But how about up and down? Gravitation limits us there.” “Not exactly,” said the Medical Man. “There are balloons.” “But before the balloons, save for spasmodic jumping and the inequalities of the surface, man had no freedom of vertical movement.” “Still they could move a little up and down,” said the Medical Man. “Easier, far easier down than up.” “And you cannot move at all in Time, you cannot get away from the present moment.” “My dear sir, that is just where you are wrong. That is just where the whole world has gone wrong. We are always getting away from the present moment. Our mental existences, which are immaterial and have no dimensions, are passing along the Time-Dimension with a uniform velocity from the cradle to the grave. Just as we should travel *down* if we began our existence fifty miles above the earth’s surface.” “But the great difficulty is this,” interrupted the Psychologist. “You *can* move about in all directions of Space, but you cannot move about in Time.” “That is the germ of my great discovery. But you are wrong to say that we cannot move about in Time. For instance, if I am recalling an incident very vividly I go back to the instant of its occurrence: I become absent-minded, as you say. I jump back for a moment. Of course we have no means of staying back for any length of Time, any more than a savage or an animal has of staying six feet above the ground. But a civilised man is better off than the savage in this respect. He can go up against gravitation in a balloon, and why should he not hope that ultimately he may be able to stop or accelerate his drift along the Time-Dimension, or even turn about and travel the other way?” “Oh, *this*,” began Filby, “is all—” “Why not?” said the Time Traveller. “It’s against reason,” said Filby. “What reason?” said the Time Traveller. “You can show black is white by argument,” said Filby, “but you will never convince me.” “Possibly not,” said the Time Traveller. “But now you begin to see the object of my investigations into the geometry of Four Dimensions. Long ago I had a vague inkling of a machine—” “To travel through Time!” exclaimed the Very Young Man. “That shall travel indifferently in any direction of Space and Time, as the driver determines.” Filby contented himself with laughter. “But I have experimental verification,” said the Time Traveller. “It would be remarkably convenient for the historian,” the Psychologist suggested. “One might travel back and verify the accepted account of the Battle of Hastings, for instance!” “Don’t you think you would attract attention?” said the Medical Man. “Our ancestors had no great tolerance for anachronisms.” “One might get one’s Greek from the very lips of Homer and Plato,” the Very Young Man thought. “In which case they would certainly plough you for the Little-go. The German scholars have improved Greek so much.” “Then there is the future,” said the Very Young Man. “Just think! One might invest all one’s money, leave it to accumulate at interest, and hurry on ahead!” “To discover a society,” said I, “erected on a strictly communistic basis.” “Of all the wild extravagant theories!” began the Psychologist. “Yes, so it seemed to me, and so I never talked of it until—” “Experimental verification!” cried I. “You are going to verify *that*?” “The experiment!” cried Filby, who was getting brain-weary. “Let’s see your experiment anyhow,” said the Psychologist, “though it’s all humbug, you know.” The Time Traveller smiled round at us. Then, still smiling faintly, and with his hands deep in his trousers pockets, he walked slowly out of the room, and we heard his slippers shuffling down the long passage to his laboratory. The Psychologist looked at us. “I wonder what he’s got?” “Some sleight-of-hand trick or other,” said the Medical Man, and Filby tried to tell us about a conjuror he had seen at Burslem, but before he had finished his preface the Time Traveller came back, and Filby’s anecdote collapsed. :::space{lines=2} :::paragraphs{style="colophon"} Set in Baskervville, Baskervville SC and Cinzel (SIL Open Font License). Text: H. G. Wells, *The Time Machine* (1895), chapter I, from Project Gutenberg eBook 35. :::
`; // content.en.md, inlined by the Cookbook // The sizes are all the worker needs. The dial is a JPEG in assets/, cut square, declared at its // pixels; outside the bezel it fades into the oxblood, so it lies on the drawn cloth unseen. const resources = [{ id: 'plate', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'plate.svg', width: 1100, height: 840 }, altText: 'Oxblood cloth framed by a double gilt rule.' }, { id: 'dial', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'dial-1120.jpg', format: 'jpeg', width: 1120, height: 1120 }, altText: 'A brass dial with four small dials on its face, on the oxblood cloth.' }]; // #region art: the title page's plate: oxblood cloth and a gilt frame, drawn in code // In tenths of a millimetre: 110 × 84 mm. The dial on it is a photograph (DIAL, below). function plate() { const P = palette; const corner = (x, y) => `<path d="M${x} ${y - 11}L${x + 11} ${y}L${x} ${y + 11}` + `L${x - 11} ${y}Z" fill="${P.gilt}"/>`; const frame = (inset, width) => `<rect x="${inset}" y="${inset}" width="${1100 - 2 * inset}" ` + `height="${840 - 2 * inset}" fill="none" stroke="${P.gilt}" stroke-width="${width}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 840">' + `<rect width="1100" height="840" fill="${P.oxblood}"/>${frame(46, 5)}${frame(62, 2)}` + [[62, 62], [1038, 62], [62, 778], [1038, 778]].map(([x, y]) => corner(x, y)).join('') + '</svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // #region fonts: one list of faces for both threads: the page loads them, the worker gets bytes const FONTS = { // every face the pages use: the page loads them, and so must the worker Baskervville: ['400', '400i'], // text, folios, subtitle, colophon 'Baskervville SC': ['500'], // running heads, the author Cinzel: ['700'], // title, numeral, chapter title }; // The worker gets the same Fontsource files the page loads: identical metrics on both threads. const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => { const [id, weight, style] = [family.toLowerCase().replace(/ /g, '-'), parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']; const file = `${id}@5/files/${id}-latin-${weight}-${style}.woff2`; return { family, weight: String(weight), style, url: `https://cdn.jsdelivr.net/npm/@fontsource/${file}` }; })); // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // The worker and the page each load the faces: the worker to measure, the page to paint. const [typeset] = await Promise.all([startLayoutWorker(faces), loadFonts(FONTS, markdown)]); await loadSvg('plate.svg', plate()); // images stay on the main thread: the worker never paints await loadImage('dial-1120.jpg', asset('dial-1120.jpg')); document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editor"> <header><span>time-machine.md · chapter I</span><label>Lay out in <select id="thread"> <option value="worker">a Web Worker</option><option value="main">the main thread</option> </select></label></header> <textarea id="source" spellcheck="false" aria-label="Markdown source"></textarea> <figure><canvas id="proof" role="img"></canvas><figcaption><button id="prev" aria-label="Previous page">‹</button><output id="folio"></output><button id="next" aria-label="Next page">›</button></figcaption></figure> <footer><svg id="beat" viewBox="-12 -12 24 24" aria-hidden="true"><circle r="11"/> <path id="hand" d="M0 2V-9"/></svg><output id="clock"></output></footer></section>`); const $ = (id) => document.getElementById(id); $('source').value = markdown; // #region editor: each keystroke sets the chapter again; the main thread only paints let [doc, shown, builds, cancelled] = [null, 0, 0, 0]; // For the comparison, the main thread keeps a measurement cache as the worker does, so its // pages match the worker's: in 1.4.1 a build with a cache can break lines differently. const mainCache = createMeasurementCache(); function paint(n = shown) { // the canvas is sized to its box, in device pixels shown = Math.max(0, Math.min(doc.pages.length - 1, n)); const vdtPage = doc.pages[shown]; // a laid-out page, not the page config above const height = ($('proof').clientHeight || 640) * Math.min(devicePixelRatio || 1, 2); renderPageToCanvas(vdtPage, doc, $('proof'), { scale: height / vdtPage.height }); $('proof').setAttribute('aria-label', `Page ${vdtPage.pageLabel}`); $('folio').value = `${shown + 1} / ${doc.pages.length}`; } function follow() { // turn to the page that holds the caret: every block keeps its source offset const caret = $('source').selectionStart; paint(doc.pages.findLastIndex((vdtPage) => vdtPage.columns.some((column) => column.blocks.some((block) => block.sourceStart <= caret)))); } async function refresh() { const [ticket, onMain, started] = [++builds, $('thread').value === 'main', performance.now()]; frames.worst = 0; const content = { markdown: $('source').value, resources }; const next = onMain ? buildDocument(content, config(), mainCache) : await typeset(content); await new Promise(requestAnimationFrame); // the first frame after the build shows any stall if (!next || ticket !== builds) { cancelled++; return; } // a newer build has been asked for doc = next; if (document.activeElement === $('source')) follow(); else paint(); showPages(doc, { title: 'The Time Machine · chapter I, set in a Web Worker' }); $('clock').value = `${onMain ? 'Main thread' : 'Worker'} · ${doc.pages.length} pages in ` + `${Math.round(performance.now() - started)} ms · longest frame ${Math.round(frames.worst)}` + ` ms · ${builds} ${builds === 1 ? 'build' : 'builds'}, ${cancelled} cancelled`; } $('source').addEventListener('input', refresh); for (const type of ['click', 'keyup']) $('source').addEventListener(type, () => doc && follow()); $('thread').addEventListener('change', refresh); $('prev').addEventListener('click', () => paint(shown - 1)); $('next').addEventListener('click', () => paint(shown + 1)); // A dial the main thread turns on every frame: it stops while that thread is busy. const frames = { last: 0, worst: 0 }; requestAnimationFrame(function turn() { // the clock, not the frame's timestamp: a late frame const now = performance.now(); // keeps the time it was due, which hides the stall frames.worst = Math.max(frames.worst, now - (frames.last || now)); frames.last = now; const deg = ((now * 0.06) % 360).toFixed(1); // a turn in 6 s $('hand').setAttribute('transform', `rotate(${deg})`); requestAnimationFrame(turn); }); await refresh(); // #endregion
Kit · core, fonts, viewer, images: igual em todas as receitas · 271 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 · 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)

Erros comuns

Erro comum

createLayoutWorker() não consegue iniciar o worker a partir de uma CDN

Chamado sem opções, createLayoutWorker() inicia o arquivo do worker que fica ao lado do módulo. Importado do esm.sh, esse arquivo está em outra origem, e o construtor Worker lança um SecurityError ('cannot be accessed from origin'). Inicie um worker de módulo a partir de um blob da mesma origem que importe https://esm.sh/postext/worker/entry e passe-o: createLayoutWorker({ worker }). Com um empacotador que sirva o postext da sua própria origem, o blob não é necessário. Layout em um Web Worker →

Erro comum

O worker de layout mede com as próprias fontes

Um worker tem o próprio FontFaceSet, então não enxerga as fontes que a página carregou. Envie a ele os bytes de cada fonte com registerFonts() antes da primeira composição, com o peso como texto ('400'). Sem eles, o worker mede com uma fonte substituta, sem erro nem aviso, e as quebras de linha e o número de páginas mudam. Layout em um Web Worker →

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

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

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

O lineHeight de um texto de design é um múltiplo, nunca uma medida

Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →

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 →

Erro comum

O ajuste de linhas curtas pode apertar um tracking que nunca é pintado

No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta. Viúvas, órfãs e linhas curtas →

  • No postext 1.4.1, uma composição com cache de medidas pode quebrar as linhas de outro jeito que uma sem cache, e o worker sempre mantém um. Na medida de 90 mm desta receita, o capítulo dá 190 linhas nos dois casos, mas com margens de 13 e 10 mm (uma medida de 87 mm) e o espaçamento entre palavras padrão ele dá 196 linhas sem cache e 199 com cache. O pen dá à thread principal um cache próprio (createMeasurementCache()), para que as duas threads continuem compondo as mesmas páginas quando você muda a medida.
  • Um import map na página não chega ao worker. Se você fixar https://esm.sh/postext@1.4.1 na página, fixe o import do blob na mesma versão, ou as duas threads podem rodar versões diferentes.

Créditos

Texto
Imagens
  • The title page’s brass dial, a photograph · Generated With Diffusion Models · original
  • The title page’s oxblood cloth and gilt frame, drawn in code in the page’s palette · Ignacio Ferro · MIT
Fontes
Baskervville (SIL OFL 1.1) · Baskervville SC (SIL OFL 1.1) · Cinzel (SIL OFL 1.1)
Sandbox