Pular para o conteúdo principal

Capítulo 3.18 · Parte II · O ofício

Uso programático: HTML, Folio e EPUB

O livro diagramado numa página web, como livro em 3D com o postext-folio e como EPUB 3 de layout fixo ou refluível com o postext-epub

Atualizado 2026-10-1618 minNo capítulo 3 Configuraçãoenescadefritptzhjaar

Em poucas palavras

Esta página é para quem escreve código e quer mostrar um livro numa tela. A primeira seção coloca as páginas dentro de uma página web e explica em que essa visualização difere do canvas e do PDF. A segunda mostra o livro em 3D, aberto sobre uma mesa, com folhas que o leitor vira. A última seção gera um livro digital EPUB, com páginas fixas ou com texto que se ajusta à tela, e verifica o arquivo.

#Integração do visualizador HTML

O visualizador HTML é o renderizador do Postext voltado para a tela. Em vez de rasterizar as páginas em um bitmap, ele emite nós DOM com posicionamento absoluto, cuja geometria vem do mesmo pipeline que produz a saída impressa. Por isso é a escolha certa quando você quer, no navegador, tipografia legível, selecionável e que acompanha o redimensionamento (um app de leitura, uma visualização dentro de um produto ou uma página de documentação incorporada) sem depender de um visualizador de PDF.

As peças principais da API pública:

  • buildDocument(content, config, cache?): executa o pipeline de layout completo e devolve um VDTDocument.
  • renderToHtmlIndexed(doc, options): transforma o VDT em uma única string HTML, mais um detalhamento por página e por bloco. Esse detalhamento permite remendar o DOM a baixo custo quando só alguns blocos mudaram entre duas renderizações.
  • resolveHtmlViewerConfig(partial): preenche os valores padrão do visualizador HTML (maxCharsPerLine, columnGap, optimalLineBreaking).
  • buildFontString + measureGlyphWidth + dimensionToPx: primitivas de medição usadas para obter a largura real da coluna em pixels a partir de um número de caracteres desejado.
  • createMeasurementCache / clearMeasurementCache: caches plugáveis, para reaproveitar medições entre um layout e outro.
  • prepareFonts / buildDocumentWithFonts / watchFonts / onFontsChanged: carregam as faces do documento antes do layout e diagramam de novo quando chegam faces (veja Carregar as fontes antes do layout).

#Exemplo ao vivo: uma string HTML

O percurso completo em JavaScript puro, antes da integração com React mais abaixo: construir o documento, entregar o VDTDocument a renderToHtml e inserir a string em um contêiner. mode: 'single' empilha as páginas na vertical; background dá uma cor a elas, já que as páginas são transparentes por padrão. O pen também imprime a marcação gerada, para você ver as linhas com posicionamento absoluto que o renderizador emite: o navegador as pinta, mas nunca refaz o fluxo delas.

Postext · renderizar um documento em HTML
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
 
const markdown = `# The Lantern
 
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
 
## Two columns
 
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
 
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
 
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
 
const config = {
  // 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
  page: { sizePreset: '17x24', dpi: 96 },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
const doc = buildDocument({ markdown }, config);
 
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
 
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;
index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
  <summary>Generated HTML</summary>
  <pre id="source"></pre>
</details>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
   The renderer centres pages with an inline style, hence the !important. */
#viewer {
  overflow: auto;
}
#viewer .pt-doc {
  align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
  margin-top: 16px;
}
#source {
  max-height: 240px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 11px;
  white-space: pre-wrap;
  word-break: break-all;
}

Carrega um editor interativo de codepen.io. O exemplo importa a versão mais recente do postext de uma CDN.

#Integração mínima

O trecho abaixo é a menor integração útil: constrói o documento no tamanho atual da área de visualização, renderiza-o em um contêiner e repete o processo a cada redimensionamento.

import { useEffect, useRef } from 'react';
import {
  buildDocument,
  renderToHtmlIndexed,
  resolveHtmlViewerConfig,
  buildFontString,
  measureGlyphWidth,
  dimensionToPx,
  createMeasurementCache,
  watchFonts,
  onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
 
// DPI adequado para tela: a 144 DPI, um corpo de 8pt equivale a 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
 
// Amostra de prosa usada para medir a largura de coluna desejada. Com fontes
// proporcionais, "N × largura média" não é confiável, então medimos uma string representativa.
const SAMPLE =
  'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
 
function sampleForChars(n: number): string {
  let s = SAMPLE;
  while (s.length < n) s += ' ' + SAMPLE;
  return s.slice(0, n);
}
 
export function PostextHtmlViewer({
  markdown,
  config,
  mode = 'multi',
}: {
  markdown: string;
  config: PostextConfig;
  mode?: 'single' | 'multi';
}) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
 
  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
 
    const relayout = () => {
      const rect = host.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
 
      const viewer = resolveHtmlViewerConfig(config.htmlViewer);
      const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
      const fontWeight = config.bodyText?.fontWeight ?? 400;
      const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
      const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
 
      // Mede a largura *real* da coluna para N caracteres de texto corrido.
      const targetColumnPx = measureGlyphWidth(
        sampleForChars(viewer.maxCharsPerLine),
        buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
      );
 
      const inner = Math.max(rect.width - PADDING_PX * 2, 100);
      let columnWidthPx: number;
      if (mode === 'single') {
        columnWidthPx = Math.min(targetColumnPx, inner);
      } else {
        // Encaixa quantas colunas couberem na largura desejada.
        const count = Math.max(
          1,
          Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
        );
        columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
      }
      columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
 
      // O modo single usa uma única página muito alta; o modo multi usa a altura
      // da área de visualização, de modo que cada "página" do VDT vira uma coluna.
      const pageHeightPx =
        mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
 
      const override: PostextConfig = {
        ...config,
        page: {
          ...config.page,
          dpi: HTML_DPI,
          width: { value: columnWidthPx, unit: 'px' },
          height: { value: pageHeightPx, unit: 'px' },
          margins: {
            top: { value: 0, unit: 'px' },
            bottom: { value: 0, unit: 'px' },
            left: { value: 0, unit: 'px' },
            right: { value: 0, unit: 'px' },
          },
        },
        layout: { ...config.layout, layoutType: 'single' },
        bodyText: {
          ...config.bodyText,
          optimalLineBreaking: viewer.optimalLineBreaking,
        },
      };
 
      const doc = buildDocument({ markdown }, override, cacheRef.current);
      const { html } = renderToHtmlIndexed(doc, {
        mode,
        columnGap: viewer.columnGap,
        padding: PADDING_PX,
        background: 'transparent',
      });
 
      host.innerHTML = html;
    };
 
    relayout();
 
    const ro = new ResizeObserver(() => relayout());
    ro.observe(host);
 
    // Mede de novo quando as fontes web chegam, para que as larguras dos glifos não venham das fontes substitutas.
    const stopWatching = watchFonts();
    const off = onFontsChanged(() => relayout());
 
    return () => {
      ro.disconnect();
      off();
      stopWatching();
    };
  }, [markdown, config, mode]);
 
  return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}

Algumas observações sobre o que esse exemplo faz:

  • Mede a coluna em vez de estimá-la. Como maxCharsPerLine é um alvo expresso em caracteres, a largura real em pixels depende da fonte do texto corrido. measureGlyphWidth faz uma medição de verdade com a fonte escolhida, o que mantém a medida constante quando a fonte muda.
  • Reescreve a página. O visualizador HTML trata cada “página” do VDT como uma coluna na tela. O exemplo substitui page.width pela largura de coluna medida, zera as margens (o espaçamento interno fica fora da página, na div .pt-doc que a envolve) e usa HTML_DPI = 144 para que um texto de 8pt resulte em 16px.
  • Leva em conta o carregamento das fontes. watchFonts escuta document.fonts e, uma vez por quadro, descarta o que foi medido nas famílias cujas faces chegaram; onFontsChanged então diagrama a coluna de novo. Sem refazer o layout, a primeira renderização usa as métricas de uma fonte substituta e salta quando a fonte verdadeira chega.
  • Reaproveita o cache de medição. Criar o cache uma vez por componente faz com que redimensionamentos e mudanças de escala da fonte reaproveitem as medições da renderização anterior, em vez de medir de novo cada parágrafo.

#Indo além

O exemplo acima é propositalmente simples. Integrações em produção costumam acrescentar:

  • Isolamento com Shadow DOM: renderize em host.attachShadow({ mode: 'open' }) para que nenhum CSS da página externa vaze para o visualizador.
  • Remendos incrementais: renderToHtmlIndexed devolve pages[i].blocks, cada um com um id estável e o HTML externo do bloco. Quando só alguns blocos diferem entre duas renderizações, você pode substituir esses invólucros no lugar, em vez de reconstruir o innerHTML.
  • Sobreposições: uma camada SVG com posicionamento absoluto sobre cada .pt-page, para cursores, seleções ou a grade de linhas de base.
  • Links: as palavras de um link Markdown são envolvidas em <a href="…" rel="noopener noreferrer">, que assume a cor do texto e não tem sublinhado; veja Formato do documento › Links. Em um visualizador que funcione como editor, intercepte os cliques em a[href] que não comecem por # e abra-os em uma nova aba (as âncoras de :ref apontam para dentro do documento).
  • Imagens em tinta única: com diagramStyle.singleInk ativado, os <img> SVG recebem um filtro CSS, a menos que você passe singleInk: false para URLs que já foram recoloridas; veja Tinta única no canvas e no HTML.

O componente HtmlPreview do Sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) implementa tudo isso sobre a mesma API mostrada aqui e pode servir de referência. Ele também encaminha cada build por um worker de layout compartilhado (veja Executar o layout em um Web Worker), para que edições ao vivo e redimensionamentos nunca bloqueiem a thread principal. Troque a chamada direta buildDocument(...) do trecho acima por layoutWorker.build(...) quando quiser tirar o layout da thread principal.

#Como a saída HTML difere do canvas e do PDF

renderToHtml posiciona cada linha, figura e elemento de design exatamente onde o canvas e o PDF os posicionam, mas pinta menos coisas ao redor deles:

RecursoCanvas (renderPage)HTML (renderToHtml)PDF (renderToPdf)
Fundo da páginaBranco, com page.backgroundColor sobre o refile e a sangria.Transparente, a menos que você passe background ou defina page.backgroundColor (que então preenche a caixa da página inteira, área de slug incluída).Branco, com page.backgroundColor sobre o refile e a sangria.
Grade de linhas de base (page.baselineGrid)DesenhadaNão desenhadaDesenhada
Fio entre colunas (layout.columnRule)DesenhadoNão desenhadoDesenhado
Marcas de corte (page.cutLines)DesenhadasNão desenhadas; a caixa da página continua incluindo a área de slug ao redor do refile.Desenhadas
Negativo da páginaOpção pageNegativeNão disponívelOpção pageNegative
TextoPixelsTexto selecionável em elementos com posicionamento absoluto, composto nas famílias de fonte do CSS: a página precisa carregar as mesmas fontes.Fontes incorporadas a partir do seu fontProvider; selecionável, pesquisável e com tags.
Texto vertical (layout.writingMode: 'vertical-rl')Caracteres pintados uma célula por vez, girados de volta à posição em pé; formas verticais por meio de uma fonte gêmea (loadVerticalAlternates).O fluxo em uma caixa girada um quarto de volta; cada linha girada de volta à posição em pé e composta com writing-mode: vertical-rl, de modo que o navegador usa as formas verticais e põe os caracteres em pé; números curtos em text-combine-upright: all; um travessão, reticências, um ponto médio ou um til ondulado em uma caixa da sua célula (o navegador o avançaria pela largura horizontal), e um travessão esticado para preenchê-la por flow.dashAdvances.Caracteres em pé por meio de uma gêmea Identity-V de cada fonte; veja Texto vertical no PDF.
ImagensregisterResourceImageA opção resourceImageUrl(fileId); sem ela, uma caixa cinza no lugar.A opção resourceBytes(fileId).
FórmulasCaminhos vetoriais<svg> embutidoCaminhos vetoriais
LinksNenhumAs citações :ref apontam para o seu recurso; as linhas do sumário, não.As citações :ref e as linhas do sumário, além dos marcadores (bookmarks).

A página transparente faz diferença em um site escuro: uma visualização sem background mostra texto preto sobre o fundo escuro do site. Passe renderToHtml(doc, { background: '#ffffff' }) ou dê ao documento um page.backgroundColor.

Os estilos de texto da página hospedeira ficam de fora. Cada linha é composta nas larguras que o motor mediu, então um letter-spacing, word-spacing, text-transform ou font-variant que a saída herdasse da página ao redor alargaria os trechos de glifos e faria as linhas se sobreporem. Por isso a raiz .pt-doc redefine as propriedades de texto herdadas (espaçamento entre letras e entre palavras, caixa, recuo, espaço em branco, estilo, variante, peso, largura, recursos e kerning da fonte, entrelinha, alinhamento, sombra e ênfase do texto, hifenização, direção, modo de escrita, contorno e preenchimento do texto e o ampliamento de texto em celulares) antes das suas próprias declarações de layout, de modo que a saída tem a mesma aparência dentro de uma shadow root ou sob um elemento estilizado. A lista é exportada como HTML_TEXT_RESET, uma string de declarações CSS: um hospedeiro que monta o innerHtml das páginas (de renderToHtmlIndexed) em contêineres próprios a aplica à raiz deles. Até o postext 1.4 a raiz não redefinia nada; a solução era um invólucro com all: initial.

#Um livro em 3D (postext-folio)

postext-folio apresenta na tela um documento diagramado como um livro impresso aberto sobre uma mesa: páginas duplas segundo a regra do recto e folhas que o leitor vira com os botões ‹ ›, as setas do teclado, um deslizar do dedo, um clique em uma página ou pegando uma página pela borda e arrastando-a. Cada folha se curva em three.js de acordo com o seu papel e projeta uma sombra real sobre as páginas de baixo. O canvas WebGL desenha o livro parado e virando da mesma forma, então uma página nunca muda de aparência ao assentar. É o visualizador das Receitas e da aba Folio do Sandbox.

npm install postext postext-folio three
import { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
 
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
  onChange: ({ pages }) => console.log('showing pages', pages),
});
 
// Depois de uma edição: o mesmo visualizador, na mesma página.
book.setDocument(buildDocument({ markdown: edited }, config));
  • As páginas são pintadas conforme são necessárias. createFolioFromDocument pinta cada página com renderPageToCanvas exatamente nos pixels de dispositivo de um espaço de página (o WebGL então a mostra texel por pixel, tão nítida quanto a visualização em canvas), e apenas as páginas duplas em torno da que está aberta (window, três de cada lado por padrão). As páginas que saem dessa janela são liberadas, então um livro de mil páginas custa a memória de algumas poucas. Um salto para uma página distante pinta primeiro aquela página dupla. Até dez páginas de distância, as folhas viram uma a uma; mais longe que isso, o bloco de páginas intermediárias se levanta como uma única placa, tão grossa quanto essas páginas (a soma das suas espessuras), e assenta do outro lado. setDocument mantém a pintura de cada página que fica igual no novo layout ({ repaint: true } pinta todas de novo, depois que uma imagem chegou).
  • O documento define o livro. A sua primeira página abre sozinha à direita quando é um recto (pageIndexOffset par), um livro com encadernação à direita (page.binding: 'right', ou um documento vertical) aparece espelhado e vira as páginas para a esquerda, as páginas em branco assumem a cor de fundo da página, e a largura de refile da página (pageWidthMm) dá a escala da espessura do papel e das capas. Um capítulo diagramado com uma continuation conta as outras páginas do livro (pageIndexOffset antes dele, bookPageCount depois dele) na espessura dos dois blocos de páginas sem desenhá-las (extraPages).
  • O documento define a aparência. O papel, a encadernação, a mesa e a luz são as configurações folio do documento (doc.config.folio). Uma página composta dentro de uma sequência :::paper leva o seu próprio papel (VDTPage.paper), e a sua folha é desenhada com a cor, a superfície, a espessura e a rigidez desse papel. Com binding.cover: 'pages', a primeira página é a capa da frente e a última, quando é um verso, a capa de trás (covers). Um formato de jornal ('broadsheet', 'berliner', 'tabloid', 'compact') cujas configurações não nomeiam papel nem encadernação aparece como papel-jornal dobrado, também quando o hospedeiro passa o seu próprio folio.
  • O contêiner define o tamanho. O livro o ocupa por inteiro, com os botões e a contagem de páginas nas margens, então dê uma altura ao contêiner; um redimensionamento pinta as páginas de novo no novo tamanho. Abaixo de 560 px de largura, ele mostra uma página por vez (mode: 'auto'; 'single' e 'double' forçam um ou outro): a lombada fica ao longo da borda interna da página e a folha vira sobre ela, arrastar em direção à lombada avança, deslizar para longe dela volta, e um toque vira a página.
  • O que o ponteiro faz. interaction (e, depois, setInteraction) define o que o botão esquerdo, um dedo ou uma caneta fazem sobre o livro: 'hand' (o padrão) pega e vira as páginas, 'orbit' gira a vista como faz o arrastar com o botão direito (para trackpads e tablets), 'select' deixa o ponteiro para o hospedeiro, para selecionar texto, por exemplo. pageAt(event) dá a página sob um ponteiro e o ponto dela ({ page, x, y }, frações da página a partir do canto superior esquerdo), no livro tal como é visto, inclinado ou girado; pointOnScreen(point) faz o caminho inverso, para desenhar um cursor de texto ou uma seleção sobre a página. refreshPage(src) volta a mostrar um canvas de página que o hospedeiro redesenhou no mesmo lugar. O Sandbox usa todos eles para selecionar texto e acompanhar o cursor do editor nas páginas em 3D.
  • Uma lupa para letras miúdas. interaction: 'magnify' mantém sobre o livro, onde está o ponteiro, uma lente redonda com aro preto (um dedo a mantém acima de si enquanto toca a tela). Ela mostra o livro como o olho do leitor o vê, iluminado e curvado como está, com o aumento máximo no centro e curvando-se em direção ao aro. A roda do mouse, + e − mudam o aumento (setMagnification(zoom), de 1,5 a 10; o padrão mostra a página a cerca de 5,5 px CSS por milímetro) e Esc a guarda; magnifier: { zoom, diameter } define os dois desde o início. createFolioFromDocument pinta de novo as páginas sob a lente, nítidas o bastante para o centro dela, de modo que o corpo de texto de um jornal fica legível; createFolio obtém essas pinturas de detail: { paint(index, deviceWidth), release() }. O Sandbox a coloca no botão Lupa (M). A lupa também seleciona texto: sobre as páginas, o cursor é o de texto, pageAt devolve o ponto sob o centro da lente (acima do dedo em uma tela sensível ao toque), e no Sandbox um clique ali posiciona o cursor de texto e um arrasto seleciona.
  • O leitor pode olhar ao redor. Arrastar com o botão direito gira a vista em órbita ao redor do livro (até 70° a partir da vertical), também enquanto as folhas viram; resetView() a devolve suavemente à tilt e ao yaw das configurações, e getView() dá a vista tal como é vista agora ({ tilt, yaw }, em graus) para guardá-la como essas configurações. O Sandbox os coloca em dois botões, Redefinir visualização e Salvar como visualização padrão.
  • Os vídeos tocam nas páginas. Um clique no pôster de um vídeo o reproduz na página, em qualquer modo de interaction, e ele continua tocando enquanto a sua folha vira; outro clique o pausa, e ele para quando o livro fica parado em uma página dupla que não o mostra. Um vídeo com player.autoplay começa sozinho na primeira vez que a sua página dupla aparece; um que também toca junto com os outros (player.exclusive: false) começa, sem som, toda vez, e para quando a página dupla é virada, vários ao mesmo tempo. Opções videos, videoUrl e onVideo, e stopVideo() no visualizador; veja Formato do documento › Vídeos nas páginas do Folio.
  • Fontes e imagens primeiro. Como no caso de renderPage, as fontes que o documento usa precisam estar carregadas em document.fonts e as suas imagens de recursos registradas com registerResourceImage antes de as páginas serem pintadas.
  • Acessível. O visualizador é um grupo focável que responde a ←/→ (espelhadas em um livro com encadernação à direita), Page Up/Down, Home e End; os seus botões e a contagem de páginas têm rótulos (labels os traduz), e cada canvas de página leva um texto alt (alt: (index) => …).
  • Sem WebGL2, ou quando o leitor pede movimento reduzido, as páginas duplas simplesmente mudam. Livros em WebGL são pesados para um celular (uma textura por lado de página): o Sandbox só oferece a aba Folio onde há WebGL2 e o lado menor da tela mede pelo menos 600 px. canFlip() diz se as folhas vão virar em 3D ali: com WebGL2 e sem movimento reduzido.

#Aparência

A opção appearance, e depois setAppearance, substituem o que o documento diz. O que ficar de fora mantém o valor do documento:

const book = createFolioFromDocument(container, doc, {
  appearance: {
    folio: {
      tilt: 22,
      paper: { type: 'bookWove', texture: 'laid' },
      binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
      surface: { type: 'walnut' },
      lighting: { environment: 'lamp' },
    },
    // Mesas fotografadas: uma pasta organizada como /folio/textures/ do postext.dev
    // (manifest.json e uma pasta por mesa). Sem ela, mapas procedurais.
    textureBaseUrl: '/folio/textures',
    // A imagem de `folio.binding.spineImage`: a URL do recurso,
    // ou um canvas ou uma imagem já desenhados.
    spineImage: spineUrl,
  },
});
 
// Um painel de configurações: o livro redesenhado no lugar, sem pintar nada de novo.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();
CampoO que faz
folioAs configurações folio: inclinação, papel, encadernação, superfície, iluminação. Substitui as do documento quando informado.
pageWidthMmA largura de refile de uma página em mm, que dá a escala da espessura do papel e das capas. A partir do documento: a página refilada no seu dpi. Padrão 150 para createFolio.
extraPages: páginas do livro além das informadas, contadas na espessura dos blocos de páginas e nunca desenhadas.
covers: a primeira página informada é a capa da frente, a última, a capa de trás (quando cai em um verso). Elas viram como capas rígidas e nenhuma caixa de capa é desenhada. A partir do documento: binding.cover: 'pages' em um livro que começa na sua primeira página e termina na última.
spineImageA imagem impressa na lombada, como URL, canvas ou imagem. createFolioFromDocument não busca recursos: passe a imagem do recurso que folio.binding.spineImage nomeia. Ignorada em uma encadernação canoa (grampeada).
textureBaseUrlOnde são servidas as texturas fotografadas da mesa. Até elas carregarem, ou sem ela, a mesa é desenhada com mapas procedurais.

createFolio(container, { pages }) é o mesmo visualizador sobre quaisquer páginas: URLs de imagens, elementos <img> ou <canvas>, e "" para uma página em branco; uma página pode ser { src, alt, paper }, com paper um papel no estilo de :::paper para aquela folha. PageFlipper é só o motor em three.js, para um hospedeiro que monta o DOM das suas próprias páginas duplas; FlatPageFlipper é o virar de página plano anterior ao livro em 3D, mantido para a mesa de luz das Receitas. A lista completa de opções está no README do pacote.

#Exemplo ao vivo: um livro em 3D

O pen importa postext e postext-folio de uma CDN, diagrama um documento curto e o abre como um livro. Pegue a página da direita pela borda e arraste-a.

Postext · um documento como livro em 3D
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
 
const paragraph = `The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved. The light it gave was small, but it was enough to find the step.`;
 
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
  .concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
  .join('\n\n');
 
const config = {
  page: { sizePreset: '17x24', dpi: 150 },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
 
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
  onChange: ({ pages }) => {
    status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
  },
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;
index.html
<p id="status">Laying out…</p>
<div id="book"></div>
style.css
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  color: #eee;
  background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
  min-height: 100vh;
}
#status {
  margin: 12px 16px 0;
  font-size: 14px;
  opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
  height: calc(100vh - 48px);
  --postext-folio-accent: #f0b35a;
}

Carrega um editor interativo de codepen.io. O exemplo importa a versão mais recente do postext de uma CDN.

#Exemplo ao vivo: imagens de página

createFolio com páginas desenhadas em canvas, uma última página em branco e a cor do papel.

Postext · um livro de imagens em 3D
import { createFolio } from 'https://esm.sh/postext-folio';
 
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
  const canvas = document.createElement('canvas');
  canvas.width = 600;
  canvas.height = 840;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbf8f1';
  ctx.fillRect(0, 0, 600, 840);
  ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
  ctx.fillRect(60, 80, 480, 320);
  ctx.fillStyle = '#222';
  ctx.font = 'bold 56px Georgia, serif';
  ctx.fillText(`Plate ${n}`, 60, 480);
  ctx.font = '22px Georgia, serif';
  for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
  ctx.textAlign = 'center';
  ctx.fillText(String(n), 300, 800);
  return { src: canvas, alt: `Plate ${n}` };
}
 
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
 
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
  pages,
  firstPageRecto: true, // page 1 opens alone, on the right
  binding: 'left', // 'right' lays a right-to-left book mirrored
  paper: '#fbf8f1',
  onChange: (state) => {
    status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
  },
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';
index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>
style.css
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  color: #eee;
  background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
  min-height: 100vh;
}
#status {
  margin: 12px 16px 0;
  font-size: 14px;
  opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
  height: calc(100vh - 48px);
  --postext-folio-accent: #f0b35a;
}

Carrega um editor interativo de codepen.io. O exemplo importa a versão mais recente do postext de uma CDN.

#Livros EPUB (postext-epub)

postext-epub grava um livro diagramado como arquivo EPUB 3.3, no navegador ou no Node, sem servidor. Ele lê os mesmos documentos de capítulo que renderToPdf recebe para um livro, de modo que números de página, notas, citações, referências cruzadas, o sumário e o índice remissivo chegam resolvidos, e devolve o arquivo como bytes. É o gerador por trás da aba EPUB 3 do Sandbox.

npm install postext postext-epub

postext é uma peer dependency, como no caso do postext-pdf: atualize os dois juntos e, em uma CDN, fixe os dois na mesma versão.

#Layout fixo e refluível

O EPUB 3 define duas apresentações, escolhidas pela propriedade rendition:layout do pacote; layout escolhe uma:

layout: 'fixed'layout: 'reflowable'
Nome no EPUBpre-paginated (layout fixo, FXL)reflowable, o padrão do EPUB
Documentos de conteúdoUm documento XHTML por página impressa, no tamanho da página refilada em px CSSUm documento XHTML por capítulo (uma parte abre um próprio)
O que mantémA página: colunas, flutuantes, cabeços, aberturas, quebras e posições de linha, nas fontes incorporadas. O texto continua sendo texto de verdade: selecionável, pesquisável, lido em voz altaO texto e a sua estrutura: títulos, parágrafos reconstruídos a partir das linhas, listas, boxes como asides, figuras e tabelas depois do texto que as cita, notas, links, marcadores de página impressa. Uma folha de estilo derivada da configuração
Do que abre mãoDa escolha do leitor de tipo, tamanho e margens; em uma tela pequena a página é reduzidaDas colunas, dos cabeços, do design de página e das quebras de linha exatas
Páginas duplas e direçãopage-spread-left / page-spread-right a partir da paridade e da encadernação; um livro com encadernação à direita é lido da direita para a esquerdaDireção de leitura a partir da encadernação; o chinês vertical mantém vertical-rl, o árabe é dir="rtl"
Indicado paraPáginas com design: livros ilustrados, livros didáticos, catálogos, revistas; telas grandesTexto corrido: romances, ensaios, relatórios; celulares e leitores de tinta eletrônica

As duas apresentações levam a mesma navegação: um sumário a partir dos títulos e das páginas de parte, uma lista de páginas com os rótulos de página impressos, landmarks (capa, sumário impresso, início do corpo do texto) e um NCX para sistemas de leitura mais antigos.

#Gravar um livro

import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
 
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // um VDTDocument por capítulo, na ordem do livro
 
const bytes = await renderToEpub(docs, {
  layout: 'reflowable',
  metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
  fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
  resourceBytes: (fileId) => {
    const data = bundle.files.get(fileId);
    return data ? { bytes: data, mediaType: '' } : undefined;
  },
  onWarning: (w) => console.warn(w),
});

Um documento único é um livro de um capítulo: renderToEpub([doc], options).

  • renderToEpub(docs, options): Promise<Uint8Array> grava o arquivo. options é { layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.
  • metadata: title e language (uma tag BCP 47) são obrigatórios; subtitle, creators, identifier, date, publisher, rights, description e modified são opcionais. Um ISBN sozinho vira urn:isbn:…. Sem um identifier, o livro recebe um urn:uuid: derivado do título, dos criadores e do idioma, para que uma nova versão do mesmo livro mantenha o seu lugar na biblioteca do leitor. Passe também modified para obter uma saída idêntica byte a byte.
  • fonts: as variantes a incorporar, { family, weight, style, bytes, format, unicodeRange? }, com format igual a woff2, woff, ttf ou otf. Cada variante vira um arquivo e uma regra @font-face; vários arquivos com o seu unicodeRange formam uma variante (as fatias do Google Fonts). Uma família, peso ou estilo que as páginas usam sem variante incorporada é informado como missingFont, e os sistemas de leitura o substituem pelos seus. Incorpore apenas fontes cuja licença permita isso: uma variante com redistributable: false nunca é gravada no arquivo (o livro pode ser composto com ela, mas o arquivo dela fica de fora, inclusive das imagens SVG).
  • resourceBytes(fileId): as imagens que as páginas posicionam, de forma síncrona ou assíncrona, como { bytes, mediaType }; um mediaType vazio é deduzido dos bytes. Entregue os bitmaps como estão armazenados e os SVGs como o seu código-fonte, não a matriz de impressão em PDF (svg.pdfFileId). Cada imagem é guardada uma única vez. Um livro em tinta única (diagramStyle.singleInk) tem os seus SVGs recoloridos no arquivo. Um SVG recebe incorporadas as variantes que o seu texto nomeia, já que um sistema de leitura o exibe como uma imagem que não enxerga as fontes do livro (veja Fontes no texto dos SVG): a partir de fonts (as fatias que contêm os seus caracteres) e, depois, de svgFonts.provider para uma família que o texto do livro não usa. As famílias de variantes marcadas com redistributable: false, e as que svgFonts.withhold(family) indica, ficam de fora e são informadas uma vez cada como fontWithheld; uma família sem variante é informada como svgFontUnavailable, e variantes acima de svgFonts.maxBytes (2 MiB), como svgFontsTooLarge. svgFonts.inline: false, diagramStyle.inlineFonts: false e o svg.inlineFonts: false de um recurso mantêm os bytes como foram entregues. Uma imagem sem bytes é informada como missingImage e fica como um quadro vazio.
  • cover: { bytes, mediaType, alt? }, uma imagem JPEG, PNG, WebP ou SVG. O livro então abre em um documento de capa que a contém, e ela é a cover-image do pacote (a miniatura na biblioteca). Sem ela, o layout fixo nomeia a primeira página como capa e o livro refluível fica sem imagem de capa.
  • onProgress({ phase, done, total }): resources (fontes e imagens), documents (páginas em um layout fixo, capítulos em um refluível) e, por fim, package. signal interrompe entre as etapas.
  • readEpub(bytes) lê um arquivo de volta para um visualizador, sem DOMParser: layout, metadados, direção de leitura, todos os arquivos por caminho, o manifesto, a spine, o sumário, a lista de páginas, o viewport do layout fixo e a capa. O leitor do Sandbox é construído sobre ele.

As duas apresentações levam metadados do EPUB Accessibility 1.1 (modos de acesso, recursos como o sumário e os números de página impressos, riscos e um resumo) e, por padrão, não declaram conformidade com as WCAG. A lista completa de opções e as limitações estão no README do pacote.

#Verificar um arquivo com o EPUBCheck

O W3C EPUBCheck é o validador de referência para EPUB; as lojas de e-books verificam com ele os arquivos que recebem. Com ele instalado (brew install epubcheck, ou a versão em Java), epubcheck book.epub lista erros, avisos e notas de uso; as notas de uso que a saída do Postext deixa (CSS-028, OBS-001, HTM_062) são apenas informativas. No repositório do Postext, pnpm --filter postext-epub epubcheck verifica os livros de exemplo do conjunto de testes, node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both diagrama um arquivo .postext ou uma pasta de predefinição e verifica as duas apresentações, e pnpm --filter postext-epub validate executa a matriz inteira de livros (o guia, as predefinições de vitrine, livros em chinês, em árabe e das Receitas), e cada um deles passa sem erros nem avisos.