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
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 unVDTDocument.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.
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
maxCharsPerLinees un objetivo expresado en caracteres, el ancho real en píxeles depende de la fuente del cuerpo.measureGlyphWidthda 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.widthcon el ancho medido de columna, pone los márgenes a cero (el padding vive fuera de la página, en el.pt-docenvolvente) y usaHTML_DPI = 144para que8ptde cuerpo resuelva a16px. - Atento a la carga de fuentes.
watchFontsescuchadocument.fontsy, una vez por fotograma, descarta lo medido en las familias cuyas caras han llegado;onFontsChangedvuelve 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 —
renderToHtmlIndexeddevuelvepages[i].blocks, cada uno con unidestable 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 reconstruirinnerHTML. - Superposiciones — apila un SVG absoluto sobre cada
.pt-pagepara 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 losa[href]que no empiecen por#y ábrelos en una pestaña nueva (las anclas de:refenlazan dentro del documento). - Imágenes a una sola tinta — con
diagramStyle.singleInkactivo, los<img>SVG llevan un filtro CSS, salvo que pasessingleInk: falseporque 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:
| Elemento | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| Fondo de página | Blanco, 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 dibuja | No se dibuja | Se dibuja |
Filete entre columnas (layout.columnRule) | Se dibuja | No se dibuja | Se dibuja |
Marcas de corte (page.cutLines) | Se dibujan | No se dibujan; la caja de la página sigue incluyendo el margen exterior de las marcas. | Se dibujan |
| Negativo de página | Opción pageNegative | No disponible | Opción pageNegative |
| Texto | Píxeles | Texto 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.) | 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ágenes | registerResourceImage | La opción resourceImageUrl(fileId); sin ella, una caja gris provisional. | La opción resourceBytes(fileId). |
| Fórmulas | Trazos vectoriales | <svg> en línea | Trazos vectoriales |
| Enlaces | Ninguno | Las 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 threeimport { 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.
createFolioFromDocumentpinta cada página conrenderPageToCanvasexactamente 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.setDocumentconserva 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 (
pageIndexOffsetpar), 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 unacontinuationsuma las demás páginas del libro (pageIndexOffsetantes,bookPageCountdespué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
foliodel documento (doc.config.folio). Una página compuesta dentro de una tanda:::paperlleva su propio papel (VDTPage.paper), y su hoja se dibuja con el color, la superficie, el grosor y la rigidez de ese papel. Conbinding.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 propiofolio. - 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éssetInteraction) 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.createFolioFromDocumentvuelve 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;createFoliotoma esas pinturas dedetail: { 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,pageAtdevuelve 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 latilty elyawde los ajustes, ygetView()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 conplayer.autoplayarranca 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 sonvideos,videoUrlyonVideo, y el visor tienestopVideo(); 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 endocument.fontsy sus imágenes registradas conregisterResourceImageantes 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 (
labelslas 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();| Campo | Qué hace |
|---|---|
folio | Los ajustes folio: inclinación, papel, encuadernación, superficie y luz. Si se indican, sustituyen los del documento. |
pageWidthMm | El 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. |
spineImage | La 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. |
textureBaseUrl | Dó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.
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.
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-epubpostext 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 EPUB | pre-paginated (maquetación fija, en inglés fixed layout o FXL) | reflowable (maquetación fluida), la de EPUB por omisión |
| Documentos de contenido | Un documento XHTML por página impresa, con el tamaño de la página refilada en px CSS | Un documento XHTML por capítulo (una parte abre uno propio) |
| Qué conserva | La 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 alta | El 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é pierde | La elección de letra, tamaño y márgenes de quien lee; en una pantalla pequeña la página se ve reducida | Las columnas, las cabeceras, el diseño de página y los cortes de línea exactos |
| Doble página y sentido | page-spread-left / page-spread-right según la paridad y la encuadernación; un libro encuadernado por la derecha se lee de derecha a izquierda | El sentido de lectura sale de la encuadernación; el chino vertical conserva vertical-rl y el árabe lleva dir="rtl" |
| Indicada para | Páginas diseñadas: libros ilustrados, libros de texto, catálogos, revistas; pantallas grandes | Texto 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.optionses{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata:titleylanguage(una etiqueta BCP 47) son obligatorios;subtitle,creators,identifier,date,publisher,rights,descriptionymodifiedson opcionales. Un ISBN sin prefijo pasa aurn:isbn:…. Sinidentifier, el libro recibe unurn: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énmodifiedpara obtener un archivo idéntico byte a byte.fonts: las variantes que se incrustan,{ family, weight, style, bytes, format, unicodeRange? }, conformatuno dewoff2,woff,ttf,otf. Cada variante pasa a ser un archivo y una regla@font-face; varios archivos con suunicodeRangeforman 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 comomissingFont, y los lectores ponen una suya. Incrusta solo las letras cuya licencia lo permita: una variante conredistributable: falsenunca 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 }; unmediaTypevací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): desdefonts(los tramos que contienen sus caracteres) y después desdesvgFonts.providerpara una familia que el texto del libro no usa. Las familias de variantes marcadas conredistributable: false, y las que nombresvgFonts.withhold(family), quedan fuera y se avisan una vez cada una comofontWithheld; una familia sin variante se avisa comosvgFontUnavailable, y las variantes que superansvgFonts.maxBytes(2 MiB), comosvgFontsTooLarge.svgFonts.inline: false,diagramStyle.inlineFonts: falsey elsvg.inlineFonts: falsede un recurso conservan los bytes tal como se dan. Una imagen sin bytes se avisa comomissingImagey 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 lacover-imagedel 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éspackage.signalinterrumpe entre pasos.readEpub(bytes)lee un archivo para un visor, sinDOMParser: 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.