Saltar al contenido principal

Capítulo 3.18 · Parte II · El oficio

Uso programático: HTML, Folio y EPUB

El libro compuesto en una página web, como libro en 3D con postext-folio y como EPUB 3 de maquetación fija o fluida con postext-epub

Actualizado 2026-10-1618 minEn el capítulo 3 Configuraciónenescadefritptzhjaar

En pocas palabras

Esta página es para quienes escriben código y quieren mostrar un libro en una pantalla. La primera sección coloca las páginas dentro de una página web y explica en qué se diferencia esa vista del canvas y del PDF. La segunda muestra el libro en 3D, abierto sobre una mesa, con hojas que el lector pasa. La última sección crea un libro electrónico EPUB, con páginas fijas o con un texto que se adapta a la pantalla, y comprueba el archivo.

#Integrar el visor HTML

El visor HTML es el renderizador de Postext orientado a pantalla. En lugar de rasterizar páginas a un bitmap emite nodos DOM posicionados absolutamente cuya geometría está generada por el mismo pipeline que produce la salida impresa. Es la opción adecuada cuando quieres tipografía legible, seleccionable y consciente del resize en el navegador — una app de lectura, una previsualización dentro de un producto o una superficie de documentación embebida — sin arrastrar contigo un visor PDF.

Las piezas clave de la API pública:

  • buildDocument(content, config, cache?) — ejecuta el pipeline completo de composición y devuelve un VDTDocument.
  • renderToHtmlIndexed(doc, options) — convierte el VDT en una única cadena HTML más un desglose por página y por bloque. El desglose permite parchear el DOM de forma barata cuando solo han cambiado algunos bloques entre renders.
  • resolveHtmlViewerConfig(partial) — completa los valores por defecto del visor HTML (maxCharsPerLine, columnGap, optimalLineBreaking).
  • buildFontString + measureGlyphWidth + dimensionToPx — primitivas de medida usadas para derivar un ancho de columna real en píxeles a partir de un objetivo en caracteres.
  • createMeasurementCache / clearMeasurementCache — cachés enchufables para reutilizar medidas entre recomposiciones.
  • prepareFonts / buildDocumentWithFonts / watchFonts / onFontsChanged — cargan las caras del documento antes de componer y vuelven a componer cuando llegan caras nuevas (ver Cargar las fuentes antes de componer).

#Ejemplo en vivo: una cadena HTML

El recorrido completo en JavaScript plano, antes de la integración con React de más abajo: construir el documento, pasar el VDTDocument a renderToHtml y volcar la cadena en un contenedor. mode: 'single' apila las páginas en vertical; background les da color, porque por defecto las páginas son transparentes. El pen también imprime el marcado generado, para que veas las líneas posicionadas de forma absoluta que emite el renderizador: el navegador las pinta, pero nunca las recompone.

Postext · renderizar un documento a 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;
}

Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.

#Integración mínima

El fragmento siguiente es la integración útil más corta: construye el documento al tamaño actual del viewport, lo renderiza en un contenedor y recompone al redimensionar.

import { useEffect, useRef } from 'react';
import {
  buildDocument,
  renderToHtmlIndexed,
  resolveHtmlViewerConfig,
  buildFontString,
  measureGlyphWidth,
  dimensionToPx,
  createMeasurementCache,
  watchFonts,
  onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
 
// DPI adecuado para pantalla: a 144 DPI un tamaño de cuerpo de 8pt resuelve a 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
 
// Muestra de prosa usada para medir el ancho objetivo de columna. Las fuentes
// proporcionales hacen poco fiable "N × ancho medio", así que medimos una
// cadena 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);
 
      // Mide el ancho *real* de columna para N caracteres de prosa del cuerpo.
      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 {
        // Encaja tantas columnas como se pueda al ancho objetivo.
        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);
 
      // El modo single usa una página muy alta; el modo multi usa la altura
      // del viewport, de modo que cada "página" VDT se convierte en una columna.
      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);
 
    // Vuelve a medir cuando cargan las web fonts para que los anchos de glifo no
    // queden tomados a partir de las fuentes de reserva.
    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' }} />;
}

Algunas notas sobre lo que hace el ejemplo:

  • Se mide la columna, no se aproxima. Como maxCharsPerLine es un objetivo expresado en caracteres, el ancho real en píxeles depende de la fuente del cuerpo. measureGlyphWidth da una medida real sobre la fuente elegida, manteniendo la medida consistente al cambiar de fuente.
  • Se reescribe la página. El visor HTML trata cada "página" del VDT como una columna en pantalla. El ejemplo sobrescribe page.width con el ancho medido de columna, pone los márgenes a cero (el padding vive fuera de la página, en el .pt-doc envolvente) y usa HTML_DPI = 144 para que 8pt de cuerpo resuelva a 16px.
  • Atento a la carga de fuentes. watchFonts escucha document.fonts y, una vez por fotograma, descarta lo medido en las familias cuyas caras han llegado; onFontsChanged vuelve a componer entonces la columna. Sin recomponer, el primer render usa métricas de la fuente de reserva y se produce un salto cuando llega la real.
  • Se reutiliza la caché de medidas. Crear la caché una sola vez por componente hace que los resizes y los cambios de escala de fuente reutilicen medidas del render anterior en lugar de volver a medir cada párrafo.

#Siguiente paso

El ejemplo de arriba es deliberadamente plano. Las integraciones en producción añaden habitualmente:

  • Aislamiento con Shadow DOM — renderiza en host.attachShadow({ mode: 'open' }) para que nada del documento externo filtre CSS al visor.
  • Parcheo incremental — renderToHtmlIndexed devuelve pages[i].blocks, cada uno con un id estable y el HTML externo del bloque. Cuando solo unos cuantos bloques difieren entre dos renders puedes reemplazar esos envoltorios en el sitio en lugar de reconstruir innerHTML.
  • Superposiciones — apila un SVG absoluto sobre cada .pt-page para cursores, selecciones o la rejilla base.
  • Enlaces — las palabras de un enlace Markdown se envuelven en <a href="…" rel="noopener noreferrer">, que toma el color del texto y no se subraya; consulta Formato del documento › Enlaces. En un visor que funcione como editor, intercepta los clics en los a[href] que no empiecen por # y ábrelos en una pestaña nueva (las anclas de :ref enlazan dentro del documento).
  • Imágenes a una sola tinta — con diagramStyle.singleInk activo, los <img> SVG llevan un filtro CSS, salvo que pases singleInk: false porque las URL ya están recoloreadas; consulta Tinta única en canvas y en HTML.

El componente HtmlPreview del sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) implementa todo esto sobre la misma API que se muestra aquí y puede servirte de referencia. Además enruta cada build a través de un worker de layout compartido (consulta Ejecutar la composición en un Web Worker) para que las ediciones en vivo y los resizes nunca bloqueen el hilo principal — sustituye la llamada directa buildDocument(...) del snippet anterior por layoutWorker.build(...) cuando quieras mover la composición fuera del hilo principal.

#En qué se diferencia la salida HTML del canvas y del PDF

renderToHtml coloca cada línea, figura y elemento de diseño exactamente donde lo hacen el canvas y el PDF, pero pinta menos a su alrededor:

ElementoCanvas (renderPage)HTML (renderToHtml)PDF (renderToPdf)
Fondo de páginaBlanco, con page.backgroundColor sobre la caja de corte y el sangrado.Transparente, salvo que pases background o fijes page.backgroundColor (que entonces rellena toda la caja de la página, incluida la zona de las marcas de corte).Blanco, con page.backgroundColor sobre la caja de corte y el sangrado.
Rejilla de línea base (page.baselineGrid)Se dibujaNo se dibujaSe dibuja
Filete entre columnas (layout.columnRule)Se dibujaNo se dibujaSe dibuja
Marcas de corte (page.cutLines)Se dibujanNo se dibujan; la caja de la página sigue incluyendo el margen exterior de las marcas.Se dibujan
Negativo de páginaOpción pageNegativeNo disponibleOpción pageNegative
TextoPíxelesTexto seleccionable en elementos con posición absoluta, compuesto en las familias CSS: la página debe cargar las mismas caras.Fuentes incrustadas desde tu fontProvider; seleccionable, buscable y etiquetado.
Texto vertical (layout.writingMode: 'vertical-rl')Caracteres pintados casilla a casilla y enderezados; formas verticales mediante una fuente gemela (loadVerticalAlternates).El flujo en una caja girada un cuarto de vuelta; cada línea enderezada y compuesta con writing-mode: vertical-rl, así que el navegador toma las formas verticales y pone los caracteres de pie; los números cortos, en text-combine-upright: all; una raya, unos puntos suspensivos, un punto medio o una tilde de onda, en una caja del largo de su celda (el navegador los haría avanzar su ancho horizontal), y la raya estirada hasta llenarla según flow.dashAdvances.Caracteres de pie mediante una gemela Identity-V de cada fuente; ver Texto vertical en el PDF.
ImágenesregisterResourceImageLa opción resourceImageUrl(fileId); sin ella, una caja gris provisional.La opción resourceBytes(fileId).
FórmulasTrazos vectoriales<svg> en líneaTrazos vectoriales
EnlacesNingunoLas citas :ref enlazan con su recurso; las entradas del índice, no.Las citas :ref y las entradas del índice, además de los marcadores.

La página transparente importa en un sitio con tema oscuro: una vista previa sin background muestra texto negro sobre el fondo oscuro del sitio. Pasa renderToHtml(doc, { background: '#ffffff' }), o da al documento un page.backgroundColor.

Los estilos de texto de la página anfitriona se quedan fuera. Cada línea se compone con los anchos que midió el motor, así que un letter-spacing, un word-spacing, un text-transform o un font-variant que la salida heredara de la página que la contiene ensancharía los tramos de glifos y las líneas se montarían unas sobre otras. Por eso la raíz .pt-doc restablece las propiedades de texto heredables —espaciado entre letras y entre palabras, caja, sangría, espacios en blanco, estilo, variante, peso, anchura, rasgos y kerning de la fuente, interlineado, alineación, sombra y énfasis del texto, guiones, dirección, modo de escritura, trazo y relleno del texto y el agrandado del texto en móviles— antes de sus propias declaraciones de maquetación, de modo que la salida se ve igual dentro de una shadow root o bajo un elemento con estilos. La lista se exporta como HTML_TEXT_RESET, una cadena de declaraciones CSS: un anfitrión que monta el innerHtml de las páginas (de renderToHtmlIndexed) en contenedores propios la aplica a su raíz. Hasta postext 1.4 la raíz no restablecía nada; el remedio era un envoltorio con all: initial.

#Un libro en 3D (postext-folio)

postext-folio presenta en pantalla un documento compuesto como un libro impreso abierto sobre una mesa: pliegos según la regla del recto y hojas que el lector pasa con los botones ‹ ›, las flechas del teclado, deslizando el dedo, con un clic en una página o tomando la página por su borde y arrastrándola. Cada hoja se curva en three.js según su papel y proyecta una sombra real sobre las páginas que tiene debajo. El lienzo WebGL dibuja el libro tanto quieto como al pasar la página, así que una página nunca cambia de aspecto al posarse. Es el visor de las recetas del Recetario y de la pestaña Folio del 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('páginas a la vista', pages),
});
 
// Tras una edición: el mismo visor, en la misma página.
book.setDocument(buildDocument({ markdown: edited }, config));
  • Las páginas se pintan a medida que hacen falta. createFolioFromDocument pinta cada página con renderPageToCanvas exactamente a los píxeles de dispositivo de su hueco (WebGL la muestra entonces téxel a píxel, tan nítida como en la vista de lienzo), y solo los pliegos cercanos al abierto (window, tres a cada lado por defecto). Las páginas que salen de esa ventana se liberan, así que un libro de mil páginas ocupa la memoria de unas pocas. Un salto a una página lejana pinta primero ese pliego. Hasta diez páginas, las hojas pasan una a una; más allá, el bloque de páginas intermedio se levanta como una sola pieza, tan gruesa como esas páginas (la suma de sus calibres), y se posa al otro lado. setDocument conserva lo pintado de cada página que queda igual en la nueva composición ({ repaint: true } las vuelve a pintar todas, por ejemplo cuando acaba de llegar una imagen).
  • El documento define el libro. Su primera página abre sola a la derecha cuando es un recto (pageIndexOffset par), un libro encuadernado por la derecha (page.binding: 'right', o un documento vertical) aparece en espejo y pasa las páginas hacia la izquierda, las páginas en blanco toman el color de fondo de la página, y el ancho de corte de la página (pageWidthMm) escala el grosor del papel y las tapas. Un capítulo compuesto con una continuation suma las demás páginas del libro (pageIndexOffset antes, bookPageCount después) al grosor de los dos bloques de páginas sin dibujarlas (extraPages).
  • El documento define el aspecto. El papel, la encuadernación, la mesa y la luz son los ajustes folio del documento (doc.config.folio). Una página compuesta dentro de una tanda :::paper lleva su propio papel (VDTPage.paper), y su hoja se dibuja con el color, la superficie, el grosor y la rigidez de ese papel. Con binding.cover: 'pages', la primera página es la tapa delantera y la última, si es un verso, la trasera (covers). Un formato de periódico ('broadsheet', 'berliner', 'tabloid', 'compact') cuyos ajustes no nombran papel ni encuadernación aparece como papel prensa doblado, también cuando el anfitrión pasa su propio folio.
  • El contenedor define el tamaño. El libro lo ocupa entero, con los botones y el contador de páginas en los márgenes, así que dale una altura; al cambiar de tamaño, las páginas se vuelven a pintar al nuevo. Por debajo de 560 px de ancho muestra una página cada vez (mode: 'auto'; 'single' y 'double' fuerzan una u otra): el lomo va por el borde interior de la página y la hoja gira sobre él; arrastrar hacia el lomo avanza, deslizar en sentido contrario retrocede, y un toque pasa la página.
  • Qué hace el puntero. interaction (y después setInteraction) fija qué hace el botón izquierdo, un dedo o un lápiz sobre el libro: 'hand' (por defecto) toma las páginas y las pasa, 'orbit' gira la vista como al arrastrar con el botón derecho (para trackpads y tabletas), 'select' deja el puntero al anfitrión, por ejemplo para seleccionar texto. pageAt(event) da la página bajo un puntero y el punto de ella ({ page, x, y }, fracciones de la página desde su esquina superior izquierda), sobre el libro tal como se ve, inclinado o girado; pointOnScreen(point) hace el camino inverso, para dibujar un cursor o una selección sobre la página. refreshPage(src) vuelve a mostrar el lienzo de una página que el anfitrión ha repintado en su sitio. El Sandbox los usa todos para seleccionar texto y seguir el cursor del editor sobre las páginas en 3D.
  • Una lupa para la letra pequeña. interaction: 'magnify' sostiene sobre el libro, donde está el puntero, un cristal redondo con aro negro (un dedo la sostiene por encima de sí mientras toca la pantalla). Muestra el libro como lo ve el ojo del lector, iluminado y curvado como está, con más aumento en el centro y curvándose hacia el aro. La rueda, + y − cambian el aumento (setMagnification(zoom), de 1,5 a 10; por defecto, la página a unos 5,5 px CSS por milímetro) y Esc la retira; magnifier: { zoom, diameter } fija ambos desde el principio. createFolioFromDocument vuelve a pintar las páginas bajo el cristal con nitidez suficiente para su centro, de modo que el cuerpo de texto de un periódico se lee; createFolio toma esas pinturas de detail: { paint(index, deviceWidth), release() }. El Sandbox la pone en el botón Lupa (M). La lupa también selecciona texto: sobre las páginas el cursor es el de texto, pageAt devuelve el punto bajo el centro de la lupa (encima del dedo en una pantalla táctil) y en el Sandbox un clic coloca ahí el cursor de edición y un arrastre selecciona.
  • El lector puede rodear el libro. Arrastrar con el botón derecho gira la vista alrededor del libro (hasta 70° desde la vertical), también mientras pasan hojas; resetView() la devuelve con suavidad a la tilt y el yaw de los ajustes, y getView() da la vista tal como se ve ahora ({ tilt, yaw }, en grados) para guardarla en esos ajustes. El Sandbox los ofrece en dos botones, Restablecer la vista y Guardar como vista por defecto.
  • Los vídeos se reproducen en las páginas. Un clic en la portada de un vídeo lo reproduce en la página, en cualquiera de los modos de interaction, y el vídeo sigue en marcha mientras pasa su hoja; otro clic lo pausa, y se detiene cuando el libro se queda quieto en un pliego que no lo muestra. Un vídeo con player.autoplay arranca solo la primera vez que se muestra su pliego; si además se reproduce junto a otros (player.exclusive: false), arranca sin sonido cada vez y se detiene cuando se pasa el pliego, varios a la vez. Las opciones son videos, videoUrl y onVideo, y el visor tiene stopVideo(); consulta Formato del documento › Vídeos en las páginas de Folio.
  • Antes, fuentes e imágenes. Como con renderPage, las fuentes del documento deben estar cargadas en document.fonts y sus imágenes registradas con registerResourceImage antes de pintar las páginas.
  • Accesible. El visor es un grupo enfocable que responde a ←/→ (en espejo para un libro encuadernado por la derecha), Re Pág/Av Pág, Inicio y Fin; sus botones y el contador de páginas llevan etiqueta (labels las traduce), y cada lienzo de página lleva texto alternativo (alt: (index) => …).
  • Sin WebGL2, o cuando el lector pide menos movimiento, los pliegos simplemente cambian. Un libro en WebGL pesa demasiado para un móvil (una textura por cada cara de página): el Sandbox ofrece su pestaña Folio solo donde hay WebGL2 y el lado corto de la pantalla mide al menos 600 px. canFlip() dice si las hojas pasarán en 3D en ese entorno: con WebGL2 y sin preferencia de menos movimiento.

#Aspecto

La opción appearance, y después setAppearance, sustituyen lo que dice el documento. Lo que no se indique conserva el valor del 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 fotografiadas: una carpeta organizada como /folio/textures/ de postext.dev
    // (manifest.json y una carpeta por mesa). Sin ella, mapas procedurales.
    textureBaseUrl: '/folio/textures',
    // La imagen de `folio.binding.spineImage`: la URL del recurso,
    // o un lienzo o una imagen ya dibujados.
    spineImage: spineUrl,
  },
});
 
// Un panel de ajustes: el libro se redibuja en su sitio, sin volver a pintar nada.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();
CampoQué hace
folioLos ajustes folio: inclinación, papel, encuadernación, superficie y luz. Si se indican, sustituyen los del documento.
pageWidthMmEl ancho de corte de una página en mm, con el que se escalan el grosor del papel y las tapas. Desde el documento: la página cortada a su dpi. Por defecto, 150 en createFolio.
extraPages: páginas del libro fuera de las que se pasan, que cuentan para el grosor de los bloques de páginas y nunca se dibujan.
covers: la primera página que se pasa es la cubierta y la última, la contracubierta (si cae en un verso). Pasan como tapas rígidas y no se dibuja ninguna tapa alrededor. Desde el documento: binding.cover: 'pages' en un libro que empieza en su primera página y termina en la última.
spineImageLa imagen impresa en el lomo, como URL, lienzo o imagen. createFolioFromDocument no busca recursos: pásale la imagen del recurso que nombra folio.binding.spineImage. No se usa en el grapado a caballete.
textureBaseUrlDónde se sirven las texturas fotografiadas de la mesa. Mientras no cargan, o sin esta opción, la mesa se dibuja con mapas procedurales.

createFolio(container, { pages }) es el mismo visor con cualquier página: URL de imágenes, elementos <img> o <canvas>, y "" para una página en blanco; una página puede ser { src, alt, paper }, con paper un papel al modo de :::paper para esa hoja. PageFlipper es el motor de three.js por separado, para quien maqueta su propio DOM del pliego; FlatPageFlipper es el paso de página plano anterior al libro en 3D, que se mantiene para la mesa de luz del Recetario. La lista completa de opciones está en el README del paquete.

#Ejemplo en vivo: un libro en 3D

El pen importa postext y postext-folio desde un CDN, compone un documento breve y lo abre como un libro. Toma la página derecha por su borde y arrástrala.

Postext · un documento como libro en 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;
}

Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.

#Ejemplo en vivo: imágenes de página

createFolio con páginas dibujadas en lienzos, una última página en blanco y el color del papel.

Postext · un libro de imágenes en 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;
}

Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.

#Libros EPUB (postext-epub)

postext-epub escribe un libro maquetado como un archivo EPUB 3.3, en el navegador o en Node, sin servidor. Lee los mismos documentos por capítulo que renderToPdf recibe para un libro, así que los números de página, las notas, las citas, las referencias cruzadas, el índice general y el alfabético llegan resueltos, y devuelve el archivo como bytes. Es el escritor que usa la pestaña EPUB 3 del Sandbox.

npm install postext postext-epub

postext es una dependencia peer, como en postext-pdf: actualiza los dos a la vez y, desde un CDN, fíjalos en la misma versión.

#Maquetación fija y maquetación fluida

EPUB 3 define dos maquetaciones, que el paquete declara con la propiedad rendition:layout; layout elige una:

layout: 'fixed'layout: 'reflowable'
Nombre en EPUBpre-paginated (maquetación fija, en inglés fixed layout o FXL)reflowable (maquetación fluida), la de EPUB por omisión
Documentos de contenidoUn documento XHTML por página impresa, con el tamaño de la página refilada en px CSSUn documento XHTML por capítulo (una parte abre uno propio)
Qué conservaLa página: columnas, flotantes, cabeceras, aperturas, cortes de línea y posiciones, con las letras incrustadas. El texto sigue siendo texto: se selecciona, se busca y se lee en voz altaEl texto y su estructura: títulos, párrafos reconstruidos a partir de las líneas, listas, recuadros como apartes, figuras y tablas tras el texto que las cita, notas, enlaces y marcas de las páginas impresas. Una hoja de estilo derivada de la configuración
Qué pierdeLa elección de letra, tamaño y márgenes de quien lee; en una pantalla pequeña la página se ve reducidaLas columnas, las cabeceras, el diseño de página y los cortes de línea exactos
Doble página y sentidopage-spread-left / page-spread-right según la paridad y la encuadernación; un libro encuadernado por la derecha se lee de derecha a izquierdaEl sentido de lectura sale de la encuadernación; el chino vertical conserva vertical-rl y el árabe lleva dir="rtl"
Indicada paraPáginas diseñadas: libros ilustrados, libros de texto, catálogos, revistas; pantallas grandesTexto corrido: novelas, ensayos, informes; móviles y lectores de tinta electrónica

Las dos maquetaciones llevan la misma navegación: un índice a partir de los títulos y las páginas de parte, una lista de páginas con los números impresos, puntos de referencia (cubierta, índice impreso, comienzo del cuerpo) y un NCX para lectores antiguos.

#Escribir un libro

import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
 
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // un VDTDocument por capítulo, en el orden del libro
 
const bytes = await renderToEpub(docs, {
  layout: 'reflowable',
  metadata: { title: 'Linterna', creators: ['Ada Lovelace'], language: 'es' },
  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),
});

Un documento suelto es un libro de un capítulo: renderToEpub([doc], options).

  • renderToEpub(docs, options): Promise<Uint8Array> escribe el archivo. options es { layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.
  • metadata: title y language (una etiqueta BCP 47) son obligatorios; subtitle, creators, identifier, date, publisher, rights, description y modified son opcionales. Un ISBN sin prefijo pasa a urn:isbn:…. Sin identifier, el libro recibe un urn:uuid: derivado del título, los autores y el idioma, de modo que una versión nueva del mismo libro conserva su sitio en la biblioteca del lector. Pasa también modified para obtener un archivo idéntico byte a byte.
  • fonts: las variantes que se incrustan, { family, weight, style, bytes, format, unicodeRange? }, con format uno de woff2, woff, ttf, otf. Cada variante pasa a ser un archivo y una regla @font-face; varios archivos con su unicodeRange forman una sola variante (los tramos de Google Fonts). Una familia, un peso o un estilo que las páginas usan sin variante incrustada se avisa como missingFont, y los lectores ponen una suya. Incrusta solo las letras cuya licencia lo permita: una variante con redistributable: false nunca se escribe en el archivo (el libro puede componerse con ella, pero su archivo queda fuera, también de las imágenes SVG).
  • resourceBytes(fileId): las imágenes que colocan las páginas, de forma síncrona o asíncrona, como { bytes, mediaType }; un mediaType vacío se lee de los bytes. Da los mapas de bits tal como están guardados y los SVG como su código fuente, no el máster PDF de imprenta (svg.pdfFileId). Cada imagen se guarda una vez. En un libro a una tinta (diagramStyle.singleInk) los SVG se recolorean en el archivo. Cada SVG lleva incrustadas las variantes que nombra su texto, porque un lector lo muestra como una imagen que no ve las fuentes del libro (consulta Fuentes en el texto de los SVG): desde fonts (los tramos que contienen sus caracteres) y después desde svgFonts.provider para una familia que el texto del libro no usa. Las familias de variantes marcadas con redistributable: false, y las que nombre svgFonts.withhold(family), quedan fuera y se avisan una vez cada una como fontWithheld; una familia sin variante se avisa como svgFontUnavailable, y las variantes que superan svgFonts.maxBytes (2 MiB), como svgFontsTooLarge. svgFonts.inline: false, diagramStyle.inlineFonts: false y el svg.inlineFonts: false de un recurso conservan los bytes tal como se dan. Una imagen sin bytes se avisa como missingImage y queda como un marco vacío.
  • cover: { bytes, mediaType, alt? }, una imagen JPEG, PNG, WebP o SVG. El libro se abre entonces con un documento de cubierta que la contiene, y es la cover-image del paquete (la miniatura en una biblioteca). Sin ella, la maquetación fija nombra cubierta a su primera página y el libro fluido no tiene imagen de cubierta.
  • onProgress({ phase, done, total }): resources (letras e imágenes), documents (páginas en la maquetación fija, capítulos en la fluida) y después package. signal interrumpe entre pasos.
  • readEpub(bytes) lee un archivo para un visor, sin DOMParser: maquetación, metadatos, sentido de lectura, cada archivo por su ruta, el manifiesto, el spine, el índice, la lista de páginas, el viewport de la maquetación fija y la cubierta. El lector del Sandbox se apoya en ella.

Las dos maquetaciones llevan los metadatos de EPUB Accessibility 1.1 (modos de acceso, prestaciones como el índice y los números de página impresos, riesgos y un resumen) y no declaran conformidad con WCAG por omisión. La lista completa de opciones y las limitaciones están en el README del paquete.

#Comprobar un archivo con EPUBCheck

W3C EPUBCheck es el validador de referencia de EPUB; las tiendas de libros electrónicos comprueban con él los archivos que reciben. Con él instalado (brew install epubcheck, o la versión Java), epubcheck libro.epub enumera errores, avisos y notas de uso; las notas de uso que deja la salida de Postext (CSS-028, OBS-001, HTM_062) son informativas. En el repositorio de Postext, pnpm --filter postext-epub epubcheck comprueba los libros de muestra de las pruebas, node packages/postext-epub/scripts/epubcheck.mjs libro.postext --layout both maqueta un archivo .postext o una carpeta de preset y comprueba las dos maquetaciones, y pnpm --filter postext-epub validate recorre toda la matriz de libros (la guía, los presets de muestra y libros en chino, en árabe y del Recetario), que pasan todos sin errores ni avisos.