Capítulo 12 · Parte II · El oficio
Configuración: uso programático
Postext desde el código: buildDocument, los Web Workers, el visor HTML, los PDF, el libro en 3D, los EPUB y los paquetes .postext
En pocas palabras
Esta página es para quienes escriben código. Enseña a componer un libro con una sola llamada a una función y a leer los avisos que devuelve. Explica cómo hacer el trabajo en segundo plano, para que la página siga respondiendo rápido. Enseña a crear una vista web, un archivo PDF, un libro en 3D y un libro electrónico EPUB. La última sección explica el archivo que lleva un libro entero con sus fuentes y sus imágenes.
#Uso programático
Camino recomendado: usa el Web Worker. En el navegador, la inmensa mayoría de las integraciones deben ejecutar el pipeline a través de
createLayoutWorker()depostext/worker, no llamando abuildDocumentdirectamente en el hilo principal. El worker mantiene la UI fluida durante los builds, cachea las mediciones de texto entre reconstrucciones incrementales y conecta la cancelación last-wins para que una nueva pulsación aborte cualquier build obsoleto en curso. Salta directamente a Ejecutar la composición en un Web Worker para la receta canónica. Todo lo del resto de esta sección (llamar abuildDocumentdirectamente, resolvers, strippers, cachés) sigue siendo útil — el worker expone exactamente las mismas entradas y salidas — pero para código de UI el envoltorio del worker es el punto de partida correcto. Solo recurre abuildDocumenten el hilo principal para exportaciones puntuales, renderizado en servidor (Node) o tests.
#Construir un documento
La función buildDocument ejecuta el pipeline de composición completo y devuelve un Árbol Virtual del Documento (VDT) con coordenadas precisas para cada elemento. Es el punto de entrada de más bajo nivel; el código de UI debería preferir el envoltorio Web Worker, que llama a buildDocument dentro de un hilo worker dedicado con los mismos argumentos.
import { buildDocument } from 'postext';
const content = {
markdown: '# Capítulo uno\n\nLa historia comienza aquí...',
};
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt sobrescribe el valor por defecto de 8 pt
};
// Construir la composición — produce un VDT con una entrada por página en `vdt.pages`
const vdt = buildDocument(content, config);
console.log(`El documento tiene ${vdt.pages.length} páginas`);#Avisos del documento
buildDocument no se detiene ante una referencia errónea o un estilo desconocido: aplica una alternativa y registra lo que hizo en doc.contentWarnings. Las cajas que la maquetación tuvo que forzar están en doc.warnings, que conserva la forma que tenía en postext 1.4: cada entrada es un calloutOverflow con su pageIndex, su columnIndex y su overflowPx. Cada campo falta cuando no hay nada que notificar. Cada entrada tiene un kind. Los tipos de contenido llevan el rango de origen de la construcción —sourceStart / sourceEnd, desplazamientos en el markdown que pasaste, frontmatter incluido— y, cuando la construcción cayó en una página, su pageIndex.
| Tipo | Se registra cuando | Qué hace la salida |
|---|---|---|
calloutOverflow | Una caja :::callout no cabe en ninguna columna y ningún corte puede partirla. Una caja con span: 'side' más alta que una columna lateral vacía también lo es (desde postext 1.25). | Se coloca de todos modos, desbordando su columna en overflowPx (en pageIndex / columnIndex). Es el único tipo que aparece en doc.warnings; los de debajo están en doc.contentWarnings. |
invalidFrontmatter | El front matter no es YAML válido (unas comillas sin cerrar, texto tras un valor entre comillas). message es el motivo del analizador, con su línea y columna. | El documento se compone sin sus metadatos; el cuerpo que sigue al --- de cierre se compone como siempre. |
unknownResourceId | Una inserción ::resource (usage: 'embed'), una referencia en línea :ref ('ref') o la imagen de una celda de tabla ('cellImage') nombra un id que no tiene ningún recurso. | La inserción se omite, la referencia imprime ? (o su etiqueta text=) sin número ni enlace, la celda queda solo con texto. inResource nombra el recurso en cuyo pie, nota o celda está la referencia. |
unknownDirective | Una línea :::nombre cuyo nombre no es ni una directiva ni un contenedor. | La línea se compone como texto. |
malformedEmbed | Una línea ::nombre que no es una inserción bien formada y aislada: ::resource con un id sin comillas o entre comillas simples o con otro atributo, o una línea pegada bajo un párrafo sin línea en blanco. | La línea se compone como texto. |
fullwidthMarkup | Una línea lleva marcas escritas con un método de entrada chino o japonés: una valla :::, un encabezado #, una llamada de nota [^…], atributos {…} tras una valla o un encabezado, o negrita **…**. typed es la marca tal como se escribió y ascii, la forma que hay que escribir. Uno por línea. | La línea se compone como texto; no se convierte nada. |
attributeKeyInvalid | Una clave de atributo lleva letras fuera del ASCII (作者=曹雪芹); señala la clave. | El atributo se ignora. |
unknownParagraphStyle | :::paragraphsstyle no nombra ningún estilo de párrafo. | Los párrafos se componen como texto de cuerpo. |
unknownCalloutType | :::callouttype no nombra ninguno de los calloutStyles; solo se registra cuando hay alguno configurado. | La caja toma el primer estilo de aviso. |
columnsFlowUnknown | :::columnsflow no es ni snake ni parallel; value es lo que dice. | El grupo toma el valor por defecto: parallel con breaks, snake sin él. |
unknownChipStyle | :chip[…]style no nombra ningún estilo de chip. | El chip toma el primer estilo de chip. |
undefinedFootnote | Una llamada de nota [^id] que ningún párrafo [^id]: define (id es el de la nota). | Se imprime el número; la nota queda vacía. |
unusedFootnote | Una definición de nota [^id]: que ninguna llamada cita. | La nota no se compone. |
indexMarkInvalid | Una marca de índice sin término: :index, o atributos sin term en una marca sin texto entre corchetes. | La marca no indexa nada. |
indexSeeUnknown | El destino de un see o un seealso (target) no es una entrada de su índice (index, '' para el principal). Señala la línea :::index. | La remisión se imprime igualmente. |
indexRangeUnclosed | Una marca range="start" sin su range="end", o al revés (missing indica qué extremo falta; term, la entrada). Señala la línea :::index. | El intervalo imprime su única página. |
unknownHeadingStyle | El style="…" de un encabezado no nombra ningún estilo de encabezado (level es el del encabezado). | El encabezado y su sección conservan los ajustes propios del nivel. |
unknownTableStyle | El table.styleId de un recurso de tabla no nombra ninguna entrada de tableStyles. | La tabla se compone con tableStyle. |
raggedTableGrid | La cuadrícula de una tabla no es rectangular una vez contadas sus combinaciones (véase Construir modelos de tabla). | Las celdas se desplazan sobre una combinación o dejan un hueco. reason ('spanOverlap' / 'missingCells'), row y col sitúan el primer problema; count indica cuántos hay. |
lineNumberOverlap | Con lineNumbers.position: 'side', un número de línea se solapa con un recuadro, un pie o una figura de la columna lateral. Señala la línea numerada; number es el número tal como se imprime. | El número se pinta igualmente, y ninguno de los dos se mueve. |
dropCap | Un párrafo que abre una capitular y que no puede llevarla tal como está configurada. reason: 'shortParagraph' (tiene menos líneas de las que baja la capitular; handling es lo que hizo shortParagraph, y lines, las líneas que abarca una capitular reducida), 'split' (se corta antes de la última línea de la capitular, solo en una columna demasiado corta), 'joiningScript' (su primera letra se enlaza con la siguiente), 'verticalText' o 'noLetter' (empieza por una referencia, una fórmula o una llamada de nota). text es su primera línea. | Se reserva el espacio, o la capitular se reduce o se omite, según diga el aviso. |
codeOverflow | Un listado de código tiene líneas más anchas que su caja. mode es lo que hizo codeStyle.overflow ('wrap', 'shrink', 'clip'), lines cuántas líneas del fuente eran demasiado anchas, scale el tamaño al que se compuso un listado reducido (una fracción de fontSize), lang el lenguaje de la valla. Señala el listado. | Las líneas se parten, se componen más pequeñas o se cortan, según diga mode. |
floatShrunk | Una imagen flotante se compuso más pequeña que su tamaño para caber en el sitio de su hueco (placement.shrink). resourceId la nombra, scale es la fracción de su anchura que conserva y overflowPx, si aparece, cuánto sobresale aún del pie de la caja de texto a su escala mínima (placement.minScale), en una página nueva donde no tenía otro sitio adonde ir. Señala el párrafo que la cita por primera vez. | La imagen se imprime a esa escala; fuera de la caja de texto solo cuando lo dice overflowPx. |
textWrap | Un recurso o una caja con el texto ajustado a su alrededor (placement.wrap, el wrap de una caja) que no se compone como se pidió. reason: 'tooNarrow' (el texto de al lado quedaría más estrecho que layout.wrap.minTextWidth), 'fewLines' (es más bajo que layout.wrap.minLinesBeside líneas), 'moved' (uno en línea demasiado alto para el espacio que queda en su columna pasó a la siguiente, con su ancla) o 'verticalText'. resourceId nombra un recurso, box el estilo de una caja. Señala la inserción o la caja. | El elemento ocupa su banda entera, o se coloca en la columna siguiente, según la razón. |
columnsTooNarrow | Las subcolumnas de un grupo :::columns son más estrechas que seis emes de su texto: columns subcolumnas de widthPx cada una. Señala la valla del grupo. | El grupo se compone como se pidió, con pocas palabras por línea. |
afterText | Una caja con span: 'side', o una figura o tabla de la columna lateral (resourceId), compuesta en una página sin texto: el texto de su capítulo, o del documento, terminó mientras esperaba sitio en la columna lateral. Uno por caja o flotante; señala la caja (o el bloque que cita el flotante), con su página. Desde postext 1.25. | Queda en la columna lateral de una página abierta después del texto, las cajas en el orden de sus vallas. |
unplaced | Una caja o un recurso flotante (resourceId) que seguía esperando un hueco cuando terminó la composición: las páginas abiertas para él no pudieron acogerlo (una caja lateral donde esas páginas no tienen columna lateral, por ejemplo). Señala la caja o el bloque que cita el recurso; sin página. Desde postext 1.25. | No está en ninguna página. Hasta postext 1.24 desaparecía sin aviso. |
fontFallback | Una cara con la que se compuso el texto (family, weight, style) que el conjunto de fuentes no podía dar cuando se ejecutó el build: reason: 'missing', no había cargada ni instalada ninguna cara de la familia, o la que responde a ese peso y esa inclinación aún no se había cargado; 'synthesized', la familia no tiene cara de ese peso o esa inclinación y el navegador la saca de otra, tal cual o engrosada o inclinada (una 600 compuesta con la 700, una cursiva 700 pedida a una familia que solo tiene una redonda 400). Se comprueba donde hay un conjunto de fuentes (document.fonts, el self.fonts de un worker o BuildDocumentOptions.fontSet), bajo debug.warnings.missingFont. No lleva página ni rango de origen. | El texto se mide y se dibuja con la fuente de reserva, o con otra cara de la familia, tal cual o engrosada o inclinada; sus cortes de línea cambian cuando llega la cara. Ver Cargar las fuentes antes de componer. |
Los avisos sobre un recurso —su estilo de tabla, su cuadrícula, una referencia dentro de su pie, su nota o sus celdas— apuntan a la primera inserción o referencia del recurso en el texto, y cada uno se registra una sola vez por recurso. Solo se comprueban los recursos que usa el documento: un capítulo de un libro notifica las tablas que cita, no todas las tablas del libro.
import { buildDocument, formatWarning } from 'postext';
const doc = buildDocument({ markdown: 'Véase :ref{id="fig-map"}.\n\n:::sidebar\nNotas.' }, config);
for (const w of [...(doc.warnings ?? []), ...(doc.contentWarnings ?? []), ...(doc.configWarnings ?? [])]) console.warn(formatWarning(w));
// Unknown resource id "fig-map" in :ref — it prints "?" (or its text= label), with no number or link (page 1, offset 6)
// Unknown directive ":::sidebar" — the line is set as text (page 1, offset 27)
// Estrecha por `kind` para leer los campos de un tipo.
const missing = (doc.contentWarnings ?? []).flatMap((w) => (w.kind === 'unknownResourceId' ? [w.resourceId] : []));formatWarning(w) devuelve una descripción en inglés de una línea. Un anfitrión que localiza sus mensajes distingue por kind —y conserva una rama por defecto, porque las versiones menores pueden añadir tipos—. collectContentWarnings(markdown, config, resources) devuelve los avisos de contenido sin maquetar nada (la lista que añade la composición, sin pageIndex), para un editor que revisa el texto mientras se escribe. collectHeadingDesignCuts(doc) revisa una maquetación terminada en busca de diseños de encabezado cuyo texto pasa del pie de su página o de su columna (kind: 'headingDesignCut'; ver Altura reservada), que la propia maquetación no avisa, y formatWarning describe también sus resultados. El panel Revisión del Sandbox los muestra todos.
Los renderizadores notifican lo que no pueden pintar como se pide mediante una opción onWarning: renderPageToCanvas, renderPage y renderToCanvas (RenderPageOptions), renderToHtml y renderToHtmlIndexed (RenderHtmlOptions), y renderToPdf (RenderToPdfOptions, también a través del worker de PDF). El principal tipo de aviso de renderizado es missingImage: una imagen —una figura, la imagen de una celda de tabla, el icono de un aviso, una imagen de diseño— sin nada que dibujar se pinta como un marcador de posición neutro y se notifica, una vez por fileId y llamada de renderizado, con su pageIndex, el resourceId cuando el renderizador lo conoce (figuras e imágenes de celda) y, en el PDF, el documentIndex de un renderizado de varios documentos. Nada que dibujar significa: ningún registerResourceImage para el fileId en el canvas, ninguna URL de resourceImageUrl en HTML, y ningún byte de resourceBytes —o bytes que no se decodifican— en el PDF. Un recurso de mapa de bits o SVG que no nombra ningún fileId no tiene nada que pedir: se dibuja como marcador de posición sin aviso. Otros dos tipos vienen de los anfitriones que incrustan fuentes en las imágenes SVG (registerSvgImage, registerBundleImages, bundleImageUrl, renderToHtml con inlineSvgFonts, postext-epub; consulta Fuentes en el texto de los SVG), con el fileId y el resourceId de la imagen: svgFontUnavailable (family, weight, style), una familia que nombra su texto sin variante que incrustar, de modo que la imagen compone ese texto con una fuente de reserva; y svgFontsTooLarge (bytes, maxBytes), variantes por encima del límite de tamaño, de las que no se incrusta ninguna. Los avisos de renderizado no se guardan en el VDT: lo que un anfitrión puede aportar cambia después de la maquetación.
import { buildDocument, renderPage, type RenderWarning, type Resource } from 'postext';
const map: Resource = {
id: 'fig-map', typeId: 'figure', kind: 'bitmap', caption: 'La ruta.', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'map-file', format: 'png', width: 1200, height: 800 },
};
const doc = buildDocument({ markdown: 'Véase :ref{id="fig-map"}.', resources: [map] }, config);
const warnings: RenderWarning[] = [];
const canvas = renderPage(doc.pages[0], doc, { onWarning: (w) => warnings.push(w) });
// Hasta que 'map-file' se registre con registerResourceImage:
// [{ kind: 'missingImage', fileId: 'map-file', resourceId: 'fig-map', pageIndex: 0 }]#Renderizar una página a un bitmap
Cada página puede rasterizarse de forma independiente. Usa renderPage(page, doc) para obtener un HTMLCanvasElement a partir del número de página — el canvas es un bitmap dimensionado exactamente al tamaño de la página en píxeles (al DPI configurado), por lo que puedes mostrarlo, exportarlo o pasarlo a cualquier pipeline de imagen:
import { buildDocument, renderPage } from 'postext';
const vdt = buildDocument(content, config);
// Renderizar la página 3 (índice base 0) como bitmap
const pageNumber = 2;
const page = vdt.pages[pageNumber];
if (!page) throw new Error(`La página ${pageNumber} no existe`);
const canvas = renderPage(page, vdt);
// canvas.width / canvas.height son el tamaño del bitmap de la página en píxeles
// Mostrarlo en el DOM
document.body.appendChild(canvas);
// …o exportarlo como PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
// …o obtener un Blob para descargar o subir
canvas.toBlob((blob) => {
if (blob) saveAs(blob, `pagina-${pageNumber + 1}.png`);
}, 'image/png');
// …o acceder a los píxeles RGBA crudos
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);Si prefieres pintar sobre un canvas que ya tienes (por ejemplo uno montado en el DOM con una disposición concreta), usa renderPageToCanvas(page, doc, canvas) — redimensiona y dibuja en el canvas que le pases en lugar de crear uno nuevo.
Para renderizar todas las páginas, itera sobre vdt.pages:
const bitmaps = vdt.pages.map((page) => renderPage(page, vdt));Ejemplo en vivo: una página como imagen
Todo lo anterior, ejecutándose en el navegador. El pen importa la última versión publicada de postext desde un CDN, espera a las fuentes web, compone un documento corto a dos columnas, pinta su primera página en un canvas y ofrece ese bitmap como PNG. Pulsa Ejecutar en CodePen para cargar el editor y cambiar el markdown o la configuración; la página se repinta con cada edición.
import { buildDocument, renderPage } 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 = {
// 150 dpi: crisp enough for a preview, light enough to paint instantly.
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
]);
// The whole layout: one entry per page in doc.pages, with exact coordinates.
const doc = buildDocument({ markdown }, config);
// Rasterise the first page. The canvas is sized to the page at the configured dpi.
const canvas = renderPage(doc.pages[0], doc);
document.getElementById('page').replaceChildren(canvas);
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · page 1 is ${canvas.width} × ${canvas.height} px`;
// The same bitmap as a PNG file.
canvas.toBlob((blob) => {
const link = document.getElementById('download');
link.href = URL.createObjectURL(blob);
link.hidden = false;
}, 'image/png');index.html
<p id="status">Laying out…</p>
<a id="download" download="page-1.png" hidden>Download page 1 as PNG</a>
<div id="page"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
margin-top: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.
#React
postext/react exporta createLayout(content, config?): un componente que compone el documento una sola vez, al montarse, y muestra cada página como un <canvas> dentro de un <div>.
import { createLayout } from 'postext/react';
const Article = createLayout(
{ markdown: '# Hola\n\nEl primer párrafo del artículo.' },
{ page: { sizePreset: '17x24' } },
);
export function ArticlePage() {
return <Article className="pages" style={{ maxWidth: 480 }} />;
}- En el hilo principal, una vez. Las páginas se pintan a la resolución del documento y se escalan al ancho del contenedor.
contentyconfigquedan fijados al llamar acreateLayout; crea otro componente para mostrar otra cosa. Para una vista previa en vivo, compón en el Web Worker y pinta conrenderPageToCanvas, como en el ejemplo de React de esa sección. - Primero, fuentes e imágenes. Carga las fuentes web del documento antes de que el componente se monte y registra sus imágenes con
registerResourceImage. Si el markdown contiene un$, el componente arranca el motor de fórmulas por su cuenta. - React se queda fuera de la entrada principal.
postextnunca importa React; solo lo hacepostext/react.createLayoutsigue exportándose desdepostextpara que el código existente siga funcionando, pero está obsoleto: cargapostext/reactcuando lo llamas, y el componente queda suspendido hasta que llega (React vuelve a renderizarlo por sí solo). Impórtalo desdepostext/react. - El componente obsoleto se suspende. Hasta que llega
postext/react, elcreateLayoutdepostextnecesita una raíz concurrente (createRoot) o un límite<Suspense>por encima. En una raíz heredada deReactDOM.render, o enrenderToString, sin ese límite, React informa de un error.reactsigue siendo una peer dependency obligatoria, para que los bundlers puedan resolver esa importación diferida.
#Resolver valores por defecto
Las funciones de resolución rellenan los valores por defecto para objetos de configuración parciales. Esto es útil cuando necesitas una configuración completa para inspección o comparación:
import { resolvePageConfig, resolveBodyTextConfig } from 'postext';
const fullPage = resolvePageConfig({ sizePreset: '21x28' });
// => { sizePreset: '21x28', width: { value: 21, unit: 'cm' }, height: { value: 28, unit: 'cm' },
// margins: { top: { value: 2, unit: 'cm' }, ... }, dpi: 300, cutLines: { enabled: false, ... }, ... }
const fullBody = resolveBodyTextConfig({ fontFamily: 'Inter' });
// => { fontFamily: 'Inter', fontSize: { value: 8, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }Resolvers disponibles, uno por sección de primer nivel: resolvePageConfig, resolveLayoutConfig, resolveBodyTextConfig, resolveHeadingsConfig, resolveHeadingStylesConfig, resolveTocConfig, resolvePartsConfig, resolveUnorderedListsConfig, resolveOrderedListsConfig, resolveMathConfig, resolveTableStyleConfig, resolveCaptionStyleConfig, resolveDiagramStyleConfig, resolveParagraphStylesConfig, resolveCalloutStylesConfig, resolveHeaderFooterConfig, resolveDebugConfig, resolveHtmlViewerConfig, resolvePdfGenerationConfig — más resolveDesignSlot para una sola ranura de diseño. Las paletas de color se aplican por separado con applyPaletteToConfig(config), applyPaletteToResolvedConfig(resolved, palette) y resolveColorValue(value, palette, fallback) — ver Paleta de colores.
Los resolvers cuyos valores por defecto heredan de otra sección reciben esa sección, ya resuelta, como argumento adicional. resolveUnorderedListsConfig y resolveOrderedListsConfig reciben el cuerpo de texto resuelto, porque las listas heredan de él fontFamily y color; resolveCalloutStylesConfig recibe el cuerpo de texto, los encabezados y las listas no ordenadas resueltos (ver el ejemplo en Estilos de aviso), y resolveHeadingStylesConfig la página, el cuerpo de texto y las dos secciones de listas resueltas. Consulta las declaraciones de tipos del paquete para la firma exacta de cada uno:
import { resolveBodyTextConfig, resolveUnorderedListsConfig } from 'postext';
const body = resolveBodyTextConfig({ fontFamily: 'Inter' });
const lists = resolveUnorderedListsConfig({ bulletChar: '—' }, body);
// => lists.fontFamily === 'Inter' (heredado)También se exportan los paquetes de valores por defecto estáticos — los que se usan cuando no hay herencia: DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, DEFAULT_PAGE_NUMBERING, PAGE_SIZE_PRESETS, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, DEFAULT_COLUMN_BALANCING, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, DEFAULT_HEADINGS_CONFIG, DEFAULT_UNORDERED_LISTS_STATIC, DEFAULT_ORDERED_LISTS_STATIC, DEFAULT_PARAGRAPH_STYLES, DEFAULT_CALLOUT_STYLES, DEFAULT_CALLOUT_STYLE_STATIC, DEFAULT_PARTS_CONFIG, DEFAULT_HEADING_STYLES, DEFAULT_TOC_CONFIG, DEFAULT_MATH_CONFIG, DEFAULT_DIAGRAM_STYLE_CONFIG, DEFAULT_DEBUG_CONFIG, DEFAULT_HTML_VIEWER_CONFIG, DEFAULT_PDF_GENERATION_CONFIG, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, además de los valores por defecto de los elementos de encabezado/pie (DEFAULT_HEADER_FOOTER_SLOT, DEFAULT_HEADER_SLOT, DEFAULT_FOOTER_SLOT, DEFAULT_TEXT_ELEMENT, DEFAULT_RULE_ELEMENT, DEFAULT_BOX_ELEMENT) y la función sensible al idioma defaultResourceTypes(locale) (ver Tipos de recurso).
#Eliminar valores por defecto
Al persistir la configuración (por ejemplo, en localStorage o un archivo), usa stripConfigDefaults para eliminar los valores que coinciden con los valores por defecto. Esto mantiene las configuraciones almacenadas mínimas — solo se guardan las modificaciones intencionadas:
import { stripConfigDefaults } from 'postext';
const minimal = stripConfigDefaults(fullConfig);
// Solo permanecen las propiedades que difieren de los valores por defectoTambién están disponibles funciones individuales, una por resolver: stripPageDefaults, stripLayoutDefaults, stripBodyTextDefaults, stripHeadingsDefaults, stripHeadingStylesDefaults, stripTocDefaults, stripPartsDefaults, stripUnorderedListsDefaults, stripOrderedListsDefaults, stripMathDefaults, stripTableStyleDefaults, stripCaptionStyleDefaults, stripDiagramStyleDefaults, stripParagraphStylesDefaults, stripCalloutStylesDefaults, stripHeaderFooterDefaults, stripDesignSlotDefaults, stripDebugDefaults, stripHtmlViewerDefaults, stripPdfGenerationDefaults.
Algunos valores por defecto dependen del resto de la configuración: el equilibrado de columnas está desactivado en una retícula de caracteres y en el texto vertical, y las notas al pie, los pies de recurso y el índice analítico siguen el idioma del documento. stripConfigDefaults compara cada valor con el valor por defecto de la configuración que recibe, de modo que headings.balancing.enabled: true se conserva donde el equilibrado está desactivado por defecto, y false se elimina ahí. Una función llamada por separado recibe ese contexto como argumentos: stripHeadingsDefaults(headings, balancingOnByDefault(config)), stripIndexDefaults(index, locale), stripCaptionStyleDefaults(captionStyle, locale), stripFootnotesDefaults(footnotes, locale, writingMode).
Un valor que dice «nada» se conserva allí donde nada no es el valor por defecto. Un estilo de encabezado toma la cabecera y el pie del documento cuando no fija los suyos, así que el estilo que los vacía (footer: { elements: [] } en una cubierta) conserva el hueco vacío, y sus margins, layout y bodyStyle se conservan aunque estén vacíos. Un nivel de encabezado devuelto a su valor por defecto bajo valores generales de los encabezados que difieren conserva el valor. calloutStyles: [] y chipStyles: [] siguen siendo listas vacías: omitidas, volvería el estilo integrado. En todos los casos resolveAllConfig(stripConfigDefaults(config)) se resuelve igual que resolveAllConfig(config).
#Parseo
El motor expone su tokenizador de markdown y su lector de frontmatter. Úsalos para inspeccionar un documento antes de construirlo, o para alimentar a otras herramientas con la misma estructura de bloques que ve Postext:
import { parseMarkdown, extractFrontmatter } from 'postext';
const source = '---\ntitle: Capítulo uno\n---\n\n# Apertura\n\nLa historia empieza aquí.';
const { metadata, content } = extractFrontmatter(source);
// metadata.title === 'Capítulo uno'
const blocks = parseMarkdown(content);
// => [ { type: 'heading', level: 1, text: 'Apertura', … },
// { type: 'paragraph', text: 'La historia empieza aquí.', … } ]Consulta la página de Formato del documento para ver la lista completa de construcciones markdown que reconoce Postext.
#Cargar las fuentes antes de componer
La composición mide el texto con las caras que tiene el conjunto de fuentes en el momento en que se ejecuta. prepareFonts carga antes del primer build todas las caras que piden una configuración y su texto: el cuerpo, los encabezados, las listas, los títulos y cuerpos de los recuadros, las tablas, los textos de los elementos de diseño, los titulillos, el índice de contenidos, el código y la rotulación de los cómics, en cada peso e inclinación que fija la configuración (las cuatro de la familia del cuerpo, porque ** y * componen en ella la negrita y la cursiva). Cada cara se carga para los caracteres que compone el documento, de modo que una familia servida en tramos de unicode-range (latín extendido, griego, árabe, los tramos CJK de Google Fonts y Fontsource) trae los archivos que el texto necesita.
import { prepareFonts, buildDocument, buildDocumentWithFonts } from 'postext';
// Los archivos del anfitrión: el contrato del proveedor de fuentes del PDF, así que una misma función sirve a los dos.
async function resolve(family: string, weight: number, style: 'normal' | 'italic') {
const id = family.toLowerCase().replace(/\s+/g, '-');
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
const report = await prepareFonts(content, config, { resolve });
// report.loaded, report.missing, report.synthesized: { family, weight, style }[]
const doc = buildDocument(content, config);
// O en una sola llamada: preparar, componer, cargar las caras que usaron las páginas y no estaban, y volver a componer.
const same = await buildDocumentWithFonts(content, config, { resolve });- Las caras que declara la página (una regla
@font-face, unFontFaceya añadido) se cargan a través del conjunto de fuentes (document.fonts.load, oself.fontsen un worker). Las que no declara se piden aresolve(family, weight, style, { text, codePoints }), que responde con un archivo (bytes o una URL), con varios (los tramos de una cara), con{ source, unicodeRange, weight, style }para un tramo o un rango variable, o connull. El motor las añade comoFontFacecuando han cargado todas, en el orden de la configuración y de cada respuesta (latin, luego latin-ext, luego greek si el resolutor responde así), de modo que el conjunto de fuentes queda igual sea cual sea el orden en que lleguen los archivos. También las registra en el registro de fuentes que leen las imágenes SVG y los workers de composición. Un resolutor puede declarar la cara por su cuenta (añadir una hoja de estilos) y respondernull. - El informe enumera en
loadedlas caras que cubre una cara cargada (o una familia instalada), enmissinglas que siguen faltando y ensynthesizedlas que el navegador sintetizaría a partir de otro peso o de otra inclinación.timeoutMs(10 000 por defecto) limita la espera; una cara que para entonces siga cargando cuenta como faltante. Donde no hay conjunto de fuentes (Node),prepareFontsno hace nada y da todas las caras por cargadas. buildDocumentWithFonts(content, config, options)prepara, compone conbuildDocumentAsync, lee las caras en las que las páginas compusieron texto de verdad, carga las que el conjunto de fuentes no podía dar (un peso que solo revelan las páginas se pide aresolveaunque otro peso de la familia pudiera responder por él) y vuelve a componer (dos composiciones más como mucho).withLoadedFonts(build, options)hace lo mismo alrededor de cualquier función de composición, para un libro que se compone capítulo a capítulo o para un paquete (buildBundle) que devuelve varios documentos.options.onFontsrecibe el informe final.- Después del build, cada cara con la que se compuso el texto y que el conjunto de fuentes no pudo dar figura en
doc.contentWarningscomofontFallback(ver Avisos del documento).
Las caras que llegan más tarde se recogen solas. El motor apunta, por cada conjunto de fuentes, qué caras de cada familia estaban cargadas la última vez que miró; un build vuelve a mirar al empezar (solo si el conjunto creció o menguó, está cargando o terminó de cargar una cara) y descarta lo medido en las familias cuyas caras cambiaron. watchFonts(fontSet) hace lo mismo a medida que cargan las caras, como mucho una vez por fotograma de animación por muchos tramos que traiga una ráfaga, y onFontsChanged(listener) le dice al anfitrión qué familias cambiaron, para que vuelva a componer las páginas:
import { watchFonts, onFontsChanged } from 'postext';
const stop = watchFonts(); // document.fonts por defecto
const off = onFontsChanged((families) => relayout());prepareFonts pone en marcha la vigilancia sobre el conjunto en el que carga (watch: false la deja apagada).
#Caché de medidas
La medición del texto es el paso costoso del proceso de composición. Dos clases de caché la abaratan:
- Una caché de bloques que es tuya.
createMeasurementCache()devuelve unaMeasurementCacheque recuerda cada párrafo medido, con su texto, sus fuentes, su ancho, sus opciones de corte de línea y el diccionario de separación silábica activo como clave. Pásala como tercer argumento debuildDocument(o debuildDocumentAsync) para reutilizar las medidas entre las pasadas de convergencia y entre composiciones: un editor que recompone el documento en cada pulsación mide entonces solo los párrafos que cambiaron. Sin ella, cada pasada vuelve a medir todos los bloques. Un párrafo leído de la caché es igual que uno medido de nuevo, así que una composición con caché corta cada línea como una sin ella; en postext 1.4.1 un párrafo guardado en caché perdía la marca de una última línea demasiado corta, y el ajuste de esas líneas y el equilibrado de columnas podían cortarlo de otra manera. La caché lleva la generación de medidas con la que se llenó: cuando llegan o se van caras de una familia, su siguiente consulta descarta los bloques compuestos en esa familia, así que una caché que se conserva mientras cargan las fuentes nunca sirve líneas medidas con la de reserva. - Cachés globales de anchos. Los anchos de palabra se guardan por cadena de fuente en un estado de módulo que comparten todas las composiciones de la página, y pretext tiene su propia caché. El motor descarta los anchos de una familia cuando cambian sus caras (al empezar un build, desde
watchFonts, desdeprepareFontsy desdeloadBundleFonts); la caché de pretext no tiene índice por familia y se vacía entera.
import { buildDocument, createMeasurementCache, evictFontFamilies, clearMeasurementCache } from 'postext';
const cache = createMeasurementCache();
let doc = buildDocument(content, config, cache);
// Se añadió a document.fonts una cara de "EB Garamond": el siguiente build la ve,
// con la misma caché, y vuelve a medir esa familia.
doc = buildDocument(content, config, cache);
// Un anfitrión que cambia caras que el motor no puede ver (un conjunto de fuentes propio) lo avisa:
evictFontFamilies(['EB Garamond']); // los anchos y los bloques en caché de esa familia
clearMeasurementCache(); // todas las familiasPara aplicaciones que miden el texto pieza a pieza, cachedMeasureBlock(text, font, maxWidthPx, lineHeightPx, options, cache) y cachedMeasureRichBlock(spans, normalFont, boldFont, italicFont, boldItalicFont, maxWidthPx, lineHeightPx, options, cache) reciben los argumentos de measureBlock y measureRichBlock más la caché, al final.
#Estado global compartido en una página
Parte del estado de Postext vive en variables de módulo. Todo lo que importa postext en el mismo contexto de JavaScript (realm) lo comparte: una página y sus scripts comparten una copia, mientras que cada iframe y cada worker tienen la suya. Con un documento por página no se nota. Con varios documentos en la misma página —dos vistas previas en vivo, una galería de ejemplos— sí:
- Imágenes de los recursos.
registerResourceImage(fileId, image)llena un único registro indexado porfileId, que leenrenderPageyrenderPageToCanvas. Dos documentos que registranfigure.svgcomparten esa entrada: gana el último registro, para los dos. Pon a los identificadores de archivo un prefijo por documento, y llama aunregisterResourceImage(fileId)o aclearResourceImages()cuando un documento desaparece. Los rásteres que guarda en caché el backend de canvas usan la misma clave y se descartan con la imagen. - Medidas del texto. Los anchos medidos se guardan en caché por cadena de fuente y texto para todo el contexto. Cuando llegan o se van caras de una familia, el motor descarta los anchos de esa familia para todos los documentos (ver Cargar las fuentes antes de componer);
clearMeasurementCache()los descarta todos. - Configuraciones resueltas. Cada objeto de configuración se resuelve una vez y el resultado queda en caché asociado a ese objeto. Antes de nada, cada build compara el objeto con el texto que tenía cuando se resolvió (un
JSON.stringify, unos 0,1 ms para una configuración de libro de 55 KB y 1,5 ms para una de 240 KB), así que una configuración modificada en el sitio, a cualquier profundidad (config.bodyText.fontSize = …, un color de la paleta), se vuelve a resolver.invalidateConfig(config)descarta la resolución a mano.stableStringifyyhashStringdan una clave de contenido que no depende del orden de las claves, para los anfitriones que guardan composiciones en caché por configuración. - Idioma de la separación silábica. Cada build fija el idioma de separación silábica de todo el proceso al
bodyText.hyphenation.localede su documento. Las funciones exportadashyphenateText(text)ylayoutDesignSlotusan el idioma del último build salvo que les pases uno: llama ahyphenateText(text, 'es'). - Motor de fórmulas. Hay un único motor MathJax y una única caché de fórmulas renderizadas por contexto;
initMathEngine()lo arranca para todos.
El aislamiento más sencillo es un contexto por documento: un iframe por ejemplo en vivo (una inserción de CodePen lo es), o un worker de composición por documento para las medidas y la separación silábica (las imágenes se siguen registrando en la página).
#Ejecutar la composición en un Web Worker
Esta es la forma recomendada de usar Postext en el navegador. Si estás construyendo algo interactivo — una previsualización en vivo, un editor, un visor sensible al resize o un playground estilo sandbox — dirige el pipeline a través de createLayoutWorker() de postext/worker. No llames a buildDocument directamente en el hilo principal para código de UI.
Llamar a buildDocument en el hilo principal ejecuta el pipeline completo — parseo, medición, siete pasadas, hasta cinco iteraciones de convergencia — en el hilo que invoca la función. Para una exportación puntual está bien. Para una interfaz interactiva es el hilo equivocado: una composición de 150 ms bloquea los eventos de entrada, las pulsaciones de teclado se encolan y el scroll se entrecorta. El worker mueve cada uno de esos milisegundos a un hilo secundario.
Postext incluye un punto de entrada dedicado a Web Worker — postext/worker — que saca el pipeline del hilo principal. Es el camino que esperamos que la mayoría de las integraciones sigan: los tres viewports del sandbox (Canvas, HTML y PDF) comparten el mismo handle createLayoutWorker() a través de un único hook useLayoutWorker (packages/postext-sandbox/src/worker/useLayoutWorker.ts) y lo dirigen con cancelación last-wins — una nueva pulsación aborta el build en curso antes incluso de que termine.
De un vistazo, la integración canónica es:
- Crea un worker una vez por viewport con
createLayoutWorker(). - Registra las fuentes una vez por familia enviando
ArrayBuffers transferibles víaregisterFonts(payloads). - Compone con
build(content, config, { signal }), pasando unAbortSignalfresco en cada llamada para poder cancelar builds obsoletos. - Supersede cualquier build anterior abortando su signal antes de iniciar el siguiente — este es el patrón last-wins.
- Libera (dispose) el worker cuando el componente propietario se desmonta.
El mismo VDTDocument que devuelve build(...) alimenta a cada renderizador descendente: renderPage/renderPageToCanvas para canvas, renderToHtmlIndexed para HTML y renderToPdf (de postext-pdf) para PDF. Construyes una vez en el worker y rasterizas tantas veces como necesite la UI en el hilo principal.
#Qué te aporta el worker
- El hilo principal queda libre. El parseo, la medición y el bucle de convergencia de siete pasadas se ejecutan todos dentro del worker. El hilo principal solo se toca cuando se publica de vuelta el
VDTDocumentterminado. - Cancelación last-wins.
build(content, config, { signal })inyecta unAbortSignalen el worker. Abortar antes de que termine produce unAbortErroren el lado principal; dentro del worker el pipeline lanza unBuildCancelledErroren el siguiente punto de comprobación por bloque y se detiene de inmediato. - Caché de medición por worker. El worker mantiene un único
MeasurementCachedurante toda su vida. Los builds posteriores que comparten fuente, texto y ancho reutilizan las mediciones cacheadas — escribir un solo carácter en un documento largo solo re-mide los bloques cuya entrada cambió realmente. - Métricas idénticas al hilo principal. Las fuentes se envían al worker como
ArrayBuffers transferibles y se registran mediantenew FontFace(...)en el propioFontFaceSetdel worker. Las mediciones usan las mismas métricas de fuente del canvas que usaría el hilo principal, de modo que las divisiones de línea y las alturas de columna son byte a byte idénticas. - La caché de rasterización matemática sobrevive a los builds. El renderizador matemático incluye una caché de rasters con clave por contenido junto a la de identidad — clonar estructuralmente un
MathRendera través de la frontera del worker fallaría si solo tuviéramos la caché por identidad.
#API pública
El cliente del worker vive en el subpath postext/worker y es un pequeño puñado de nombres:
createLayoutWorker(opts?): LayoutWorkerHandle— instancia un worker dedicado (o envuelve uno que pases medianteopts.worker, o arranca la entrada del worker que está enopts.url) y devuelve un handle tipado. Consulta Cargar el worker desde una CDN.LayoutWorkerHandle.registerFonts(faces: FontPayload[]): Promise<void>— envía bytes de fuente al worker. Los buffers se transfieren, así que guarda una copia fresca en el hilo principal si luego los necesitas.LayoutWorkerHandle.build(content, config?, { signal? }): Promise<VDTDocument>— ejecuta el pipeline. Abortar la señal cancela el build en curso.LayoutWorkerHandle.dispose(): void— termina el worker y rechaza cualquier build pendiente conAbortError.FontPayload—{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }.weightes un peso CSS en forma de cadena ('700','bold');registerFontstambién lo acepta como número (700). Elbufferse transfiere al worker cuando llamas aregisterFonts.BuildCancelledError(re-exportado desdepostext) — lo que lanzabuildDocumentinternamente cuandooptions.shouldCanceldevuelvetrue. Normalmente no lo ves en el hilo principal: el protocolo del worker lo convierte en unAbortErrorantes de llegar a tu código.
El paquete también publica el path postext/worker/entry, apuntando al script compilado del worker. createLayoutWorker() resuelve esa URL automáticamente; solo necesitas referenciarla explícitamente cuando tu bundler exige una llamada manual a new Worker(new URL(...), { type: 'module' }), o cuando sirves la entrada tú mismo (opts.url).
#Integración mínima
import { createLayoutWorker } from 'postext/worker';
import type { FontPayload, LayoutWorkerHandle } from 'postext/worker';
import type { PostextConfig, VDTDocument } from 'postext';
// 1. Crea el worker una única vez y conserva el handle durante toda la vida de tu viewport.
const layout: LayoutWorkerHandle = createLayoutWorker();
// 2. Registra las fuentes una vez por familia (ArrayBuffers transferibles).
// getConfigFontFamilies(config) es un helper que lista las familias que tu config va a renderizar.
const payloads: FontPayload[] = await collectFontPayloadsForFamilies([
'EB Garamond',
'Open Sans',
]);
await layout.registerFonts(payloads);
// 3. Dirige los builds con cancelación last-wins: aborta el signal anterior
// antes de iniciar uno nuevo. Un build obsoleto se descarta dentro del worker.
let pending: AbortController | null = null;
async function rebuild(
markdown: string,
config: PostextConfig,
): Promise<VDTDocument | null> {
pending?.abort();
pending = new AbortController();
try {
return await layout.build({ markdown }, config, { signal: pending.signal });
} catch (err) {
if ((err as { name?: string } | null)?.name === 'AbortError') return null;
throw err;
}
}
// 4. Libera (dispose) cuando el componente propietario del worker se desmonta.
// Los builds pendientes rechazan con AbortError.
layout.dispose();Envuelto en un componente React, la forma es:
import { useEffect, useRef } from 'react';
import { createLayoutWorker } from 'postext/worker';
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderPageToCanvas } from 'postext';
import type { PostextConfig } from 'postext';
export function CanvasPreview({
markdown,
config,
}: {
markdown: string;
config: PostextConfig;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const workerRef = useRef<LayoutWorkerHandle | null>(null);
const pendingRef = useRef<AbortController | null>(null);
// Montaje: arranca el worker y envía las fuentes una sola vez.
useEffect(() => {
const handle = createLayoutWorker();
workerRef.current = handle;
(async () => {
const payloads = await collectFontPayloadsForFamilies(
getConfigFontFamilies(config),
);
await handle.registerFonts(payloads);
})();
return () => {
pendingRef.current?.abort();
handle.dispose();
};
}, []); // las fuentes se registran una vez; re-regístralas solo cuando cambie el conjunto de familias
// En cada tecla o cambio de config: supersede el build en curso y lanza uno nuevo.
useEffect(() => {
const handle = workerRef.current;
if (!handle) return;
pendingRef.current?.abort();
const ac = new AbortController();
pendingRef.current = ac;
(async () => {
try {
const vdt = await handle.build({ markdown }, config, { signal: ac.signal });
const canvas = canvasRef.current;
if (!canvas || !vdt.pages[0]) return;
renderPageToCanvas(vdt.pages[0], vdt, canvas); // rasteriza en el hilo principal
} catch (err) {
if ((err as { name?: string } | null)?.name !== 'AbortError') throw err;
}
})();
}, [markdown, config]);
return <canvas ref={canvasRef} />;
}El patrón siempre es el mismo: crea una vez, registra las fuentes una vez, construye-con-AbortSignal muchas veces, libera al desmontar.
#Cargar el worker desde una CDN
El script de un worker tiene que venir del mismo origen que la página, así que una copia de postext/worker servida por una CDN no puede arrancar el archivo layout.worker.js que tiene al lado. createLayoutWorker() lo resuelve:
- esm.sh, sin opciones. Cuando el propio
postext/workerse cargó desde esm.sh (la URL de su módulo tiene la formahttps://esm.sh/postext@1.5.0/es2022/worker.mjs), el cliente arranca la entrada correspondiente,https://esm.sh/postext@1.5.0/worker/entry, a través de un módulo blob de una línea, del mismo origen, que la importa. Lo mismo vale para las importaciones que añaden?deps=,?external=o?alias=, y para la formahttps://esm.sh/*postext@1.5.0/worker. El worker recibe siempre la compilación normal de esa versión, porque un worker no tiene import map con el que resolver dependencias externas. - Cualquier otro servidor, con
url. Las demás CDN, como jsDelivr (/+esm) o unpkg, no se detectan.createLayoutWorker({ url })arranca el módulo de entrada del worker que está enurl: directamente si es del mismo origen, y a través del mismo envoltorio blob si es de otro. Ese servidor debe permitir peticiones de otros orígenes (CORS). - Con un bundler no cambia nada. Con Vite, webpack o Next.js, sigue llamando a
createLayoutWorker()sin opciones: ellos emiten el worker como un fragmento más de tu aplicación.
import { createLayoutWorker } from 'https://esm.sh/postext/worker';
const layout = createLayoutWorker();
const face = async (weight, style) => ({
family: 'EB Garamond',
weight,
style,
buffer: await (await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/eb-garamond@5/files/eb-garamond-latin-${weight}-${style}.woff2`)).arrayBuffer(),
});
await layout.registerFonts(await Promise.all([face(400, 'normal'), face(700, 'normal'), face(400, 'italic')]));
const doc = await layout.build({ markdown }, { bodyText: { fontFamily: 'EB Garamond' } });Un worker no ve las fuentes de la página. Tiene su propio conjunto de fuentes, que solo contiene las caras enviadas con registerFonts y las fuentes instaladas en el sistema. Cuando un build compone texto en una familia que el worker no encuentra, ese texto se mide con una fuente de reserva, así que sus cortes de línea no coincidirán con los de la página. El cliente imprime entonces un aviso en la consola por familia ("EB Garamond" is not available inside the layout worker…) y enumera las familias en BuildStats.missingFonts, que recibe el callback onStats de build. El propio documento lleva esas mismas caras como avisos de contenido fontFallback.
handle.prepareFonts(content, config, options) ejecuta prepareFonts en la página y después envía al worker los archivos de cada cara encontrada que guarda el registro de fuentes (los archivos del resolutor, las caras de un paquete, las reglas @font-face legibles de la página), y de ellos solo los tramos que contienen caracteres del documento. Cuando llegan caras al worker, este descarta solo las medidas de esas familias, además de su caché de documentos terminados.
#Recolección de payloads de fuente (Fontsource / Google Fonts)
registerFonts toma bytes de fuente en crudo. El hilo principal es el lugar adecuado para buscarlos, porque Google Fonts solo devuelve WOFF2 a User-Agents con pinta de navegador, y porque una caché centralizada permite que varias instancias del worker compartan los mismos bytes.
collectFontPayloadsForFamilies del sandbox (packages/postext-sandbox/src/controls/fontLoader.ts) es una implementación de referencia directa. Lo que hace:
- Consulta
https://api.fontsource.org/v1/fonts/{id-de-familia}para descubrir los pesos disponibles y si la familia incluye un eje variable. - Construye una URL CSS2 de Google Fonts que cubre todos los pesos y estilos que declara la familia.
- Descarga la hoja de estilos
@font-facegenerada, extrae cada declaraciónsrc: url(...) format('woff2')y descarga los bytes en crudo. - Devuelve un
FontPayload[]dondebufferes unArrayBufferfresco por llamada — importante, porqueregisterFontstransfiere el buffer y deja la copia del remitente desvinculada.
Combínalo con getConfigFontFamilies(config) para obtener la lista de familias que una PostextConfig concreta va a renderizar (cuerpo, encabezados, viñetas de listas, números de listas ordenadas).
#Cancelación cooperativa dentro del motor
Si estás orquestando buildDocument tú mismo — por ejemplo, dentro de un worker personalizado — el pipeline expone un hook shouldCancel que puedes usar directamente:
import { buildDocument, BuildCancelledError } from 'postext';
let superseded = false;
try {
const vdt = buildDocument(content, config, cache, {
shouldCancel: () => superseded,
});
} catch (err) {
if (err instanceof BuildCancelledError) return; // un build más nuevo tomó el relevo
throw err;
}shouldCancel se invoca una vez por cada bloque de nivel superior durante la colocación. El hook es intencionadamente cooperativo — no puede detener la propia llamada de layout de Pretext a mitad de una línea, pero mantiene la granularidad de cancelación lo bastante fina (milisegundos) como para que un usuario tecleando rápido nunca espere por un build obsoleto.
#Exportar PDF desde el worker
El backend PDF toma un VDTDocument ya listo y lo convierte en bytes PDF. No vuelve a ejecutar la composición. Eso significa que el flujo canónico de PDF en el navegador encaja limpiamente con el worker: construye el VDT en el worker (fuera del hilo principal, cancelable, reutilizando la caché), y luego llama a renderToPdf en el hilo principal sobre ese mismo VDT.
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderToPdf } from 'postext-pdf';
import type { PostextConfig } from 'postext';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function exportPdf(
layout: LayoutWorkerHandle,
markdown: string,
config: PostextConfig,
): Promise<Uint8Array> {
// 1. Construye el VDT en el worker — la UI se mantiene fluida durante las pasadas de composición.
const vdt = await layout.build({ markdown }, config);
// 2. Rasteriza a PDF en el hilo principal. renderToPdf es rápido una vez que existe el VDT
// porque recorre coordenadas precalculadas, no vuelve a medir texto.
return renderToPdf(vdt, {
fontProvider,
// La config `pdfGeneration` de `vdt.config` se respeta automáticamente.
});
}Si ya mantienes un handle de worker para la previsualización en vivo, reutilízalo para la exportación en vez de levantar un segundo worker — la caché de medición dentro del worker hace que una exportación PDF posterior a una previsualización en pantalla sea esencialmente gratis.
En un libro largo, escribir el propio PDF también lleva segundos; postext-pdf/worker ejecuta ese paso en un worker propio (consulta Renderizar el PDF en un worker).
#Cuándo usar el worker y cuándo no
Usa el worker para:
- Previsualizaciones en vivo, editores y playgrounds. Cualquier escenario donde el documento se reconstruye en respuesta a la entrada del usuario.
- Visores HTML sensibles al resize que re-ejecutan la composición en cada tick del
ResizeObserver. - Exportación PDF desde el navegador disparada desde una UI que ya tiene previsualización en vivo — reutiliza el handle de worker existente para aprovechar la caché de medición.
- Múltiples pestañas de salida que necesitan el mismo VDT (los viewports Canvas / HTML / PDF del sandbox comparten un handle de worker por montaje de viewport).
Sáltatelo para:
- Generación en servidor — Node no tiene un
FontFaceSetdel navegador, y controlas el hilo de todos modos. - Exportaciones puntuales aisladas (un CLI, un script de exportación headless, una Cloud Function) en las que no existe una UI interactiva que se pueda bloquear. Llamar a
buildDocumentdirectamente es más simple y evita el coste de la transferencia inicial de fuentes.
#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.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á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.
#Generación de PDF
La salida PDF vive en un paquete separado, postext-pdf, para que las integraciones puramente web no paguen el coste de pdf-lib ni de @pdf-lib/fontkit. El backend de PDF no vuelve a medir el texto: consume exactamente el mismo VDTDocument que le pasarías a renderToCanvas o renderToHtml y traduce sus coordenadas en píxeles a puntos PDF. Las tres salidas están garantizadas, por tanto, a coincidir en saltos de línea, alturas de columna y colocación de recursos.
En el navegador, construye el VDT a través del Web Worker.
renderToPdfen sí es rápido una vez que existe el VDT — la parte costosa es el pipeline de composición que lo produjo. Ejecutar ese pipeline en el worker mantiene la UI fluida y permite que una exportación PDF reutilice la misma caché de medición que ya calentó la previsualización en vivo. Consulta Exportar PDF desde el worker para el flujo recomendado. Los ejemplos en hilo principal que siguen son la referencia de qué significan los argumentos — para código de UI, construye primero el VDT en el worker y llama arenderToPdfdirectamente.
#Instalación
npm install postext postext-pdfpostext es una peer dependency de postext-pdf. Cada versión de postext-pdf necesita el postext con el que se publicó, o uno posterior de la misma versión mayor (su rango es ^ esa versión, ^1.5.0 para la 1.5.0), porque importa funciones que postext añadió en esa versión. Actualiza los dos a la vez y, en un CDN, fíjalos a la misma versión.
#API pública
El paquete expone un único punto de entrada y un puñado de tipos:
renderToPdf(doc, options): Promise<Uint8Array>— toma unVDTDocument(o los capítulos de un libro, como una lista de ellos) y devuelve los bytes crudos del PDF.PdfFontProvider— la firma de callback(family, weight, style, request?) => Promise<Uint8Array | Uint8Array[]>querenderToPdfusa para pedir los bytes de una fuente cuando necesita incrustar una combinación family/weight/style nueva.request.codePointscontiene los caracteres que las páginas componen en esa variante; la respuesta es un archivo, o varios que juntos forman la variante (consulta Fuentes chinas, japonesas y coreanas).RenderToPdfOptions—{ fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg?, harfbuzzWasm?, print?, outputProfile?, profileBaseUrl? }.outlines,accessibleycolorSpacetoman el valor delpdfGenerationdel documento cuando no se pasan (ver Generación de PDF (configuración)).resourceBytesse describe en Bytes de recursos y másteres de impresión;onWarning, en Qué variantes se piden al proveedor y en Avisos del documento. ConcharacterGrid: trueel PDF imprime la retícula quecjk.grid.showdibuja en pantalla y que de otro modo deja fuera (ver Retícula de caracteres).harfbuzzWasmindica de dónde cargar elharfbuzz.wasmde HarfBuzz (una URL, relativa a la página, o los bytes del archivo) para un documento con texto de derecha a izquierda o de letras enlazadas; si no se pasa, la copia junto al módulo de postext-pdf y, después, la misma versión de harfbuzzjs en jsDelivr y en esm.sh.printrecibe los ajustes de salida a imprenta (norma PDF/X, perfil de salida, negro, preflight) y, si no se pasa, toma elprintdel documento; con una norma PDF/X, o concolorSpace: 'cmyk', cada color se separa con el perfil ICC de salida, cuyos bytes daoutputProfile(si no, se descarga deprofileBaseUrl, por defecto la copia de la carpetaicc/de postext en la CDN de npm).PdfWarning— un problema no fatal que se notifica poronWarning; se distingue porkind:'fontFallback'(PdfFontFallbackWarning), una variante compuesta con otro corte de su familia;'missingGlyph'(PdfMissingGlyphWarning), caracteres para los que ningún archivo de una variante tiene glifo;'variableFontDefaultInstance'(PdfVariableFontWarning), una fuente variable pedida con un peso distinto del de su instancia por defecto;'cffEmbeddedWhole'(PdfCffEmbeddedWholeWarning), una fuente CFF de más de 2 MB incrustada entera;'complexShapingUnavailable'(PdfComplexShapingWarning), texto de derecha a izquierda o de letras enlazadas dibujado sin HarfBuzz, que no se pudo cargar (reasondice dónde se buscó), así que las marcas del árabe quedan mal colocadas;'outputProfileUnavailable'(PdfPrintWarning), un render CMYK cuyo perfil de salida no se pudo cargar y que se convirtió con la fórmula simple;'pageNegativeIgnored'(PdfPrintWarning), el negativo de página que se omite en un PDF/X-1a; o'missingImage', una imagen sin bytes dibujada como marcador de posición (esta solo se notifica a unonWarningpropio; sin él, los avisos de fuentes van aconsole.warn).decompressWoff2(bytes): Uint8Array— helper que convierte un archivo WOFF2 en bytes TTF, que es el formato quepdf-libpuede incrustar directamente.createPdfWorker(options?), depostext-pdf/worker— el mismo render en un Web Worker; consulta Renderizar el PDF en un worker.
#Ejemplo mínimo
import { buildDocument } from 'postext';
import { renderToPdf } from 'postext-pdf';
const vdt = buildDocument(
{ markdown: '# Capítulo uno\n\nLa historia empieza aquí…' },
{
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt sobrescribe el valor por defecto de 8 pt
},
);
const pdfBytes = await renderToPdf(vdt, {
fontProvider: async (family, weight, style) => {
// Devuelve los bytes TTF para esta family/weight/style.
// Consulta la sección "Proveedor de fuentes" más abajo para una implementación real.
const res = await fetch(`/fonts/${family}-${weight}${style === 'italic' ? 'i' : ''}.ttf`);
return new Uint8Array(await res.arrayBuffer());
},
});
// `pdfBytes` es un Uint8Array — guárdalo, descárgalo o envíalo por streaming.
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);#¿Por qué un proveedor de fuentes?
pdf-lib incrusta archivos de fuente reales dentro del PDF — las fuentes instaladas en el navegador no están disponibles en el momento del render, y una fuente que solo cargaste para medición en pantalla no basta, por sí sola, para producir un PDF autocontenido. renderToPdf recorre las páginas buscando cada variante que pintan (una por cada combinación family|weight|style; consulta Qué variantes se piden al proveedor) e invoca tu proveedor una sola vez por combinación única. El proveedor devuelve un Uint8Array con bytes TTF u OTF, o una lista de ellos para una variante servida en varios archivos (consulta Fuentes chinas, japonesas y coreanas); pdf-lib reduce los contornos TrueType a los glifos usados e incrusta enteros los archivos CFF (.otf). Las variantes a las que el proveedor responde con el mismo archivo, como una redonda que sustituye a la negrita que falta en una familia, comparten una sola fuente incrustada. Una variante con la que ninguna página llega a dibujar, como la del texto de una figura SVG que acaba dibujada como imagen, no se escribe en el archivo.
Usa fuentes estáticas por peso, no una única fuente variable. Google Fonts a menudo sirve un único WOFF2 variable por familia que cubre todo el eje de pesos. pdf-lib solo puede incrustar la instancia por defecto de un archivo variable, por lo que un párrafo en negrita se renderizaría con peso regular. Fontsource publica archivos WOFF2 estáticos por peso que resuelven esto limpiamente — es el patrón que usa el sandbox. Un archivo variable pedido con un peso distinto del de su instancia por defecto se notifica con un aviso variableFontDefaultInstance.
Cada palabra del texto cae donde la puso la maquetación. En los párrafos, los elementos de lista, las citas, los recuadros y el resto del texto corrido, cada palabra empieza en la posición que midió el VDT, así que una diferencia entre los anchos del navegador y los de la fuente incrustada nunca se acumula a lo largo de una línea. Una línea sin formato en línea se pinta como un solo objeto de texto que mueve el punto de escritura entre palabra y palabra; las líneas justificadas, las centradas y las que llevan formato se pintan palabra a palabra. Un carácter para el que la fuente no tiene glifo, que el navegador midió con otra fuente y que el PDF pinta como la caja de glifo ausente de la fuente, no mueve ninguna de las palabras que le siguen, y se notifica una vez por variante con un aviso missingGlyph. Un espacio que la fuente no tiene, como el espacio fino de no separación o el espacio de cifra, toma el ancho que le dio el navegador, y los caracteres invisibles, como la unión de palabras y el espacio de anchura cero, no se dibujan. Un guion de no separación (U+2011) que la fuente no tiene se dibuja con el guion de la fuente (U+2010), o con su guion-menos si tampoco tiene guion, como lo muestra el navegador; Open Sans y Outfit, entre otras, carecen de los dos. Ninguno de estos casos cuenta como glifo ausente. Hay dos excepciones. Una línea con letras de derecha a izquierda se sigue pintando como un solo tramo (consulta Idiomas y escrituras). El texto que coloca un diseño (encabezados y pies corridos, aperturas, títulos de recuadro y otros elementos de diseño) se compone con los anchos propios de la fuente incrustada, así que allí un glifo que falta en la fuente sigue moviendo el resto de su línea.
#Qué variantes se piden al proveedor
renderToPdf recorre las páginas en el mismo orden en que las pinta y solo pide al proveedor las variantes que dibujan:
- la variante regular de un bloque que compone alguna línea, y la negrita, cursiva o negrita cursiva de cada tramo que de verdad lleva ese estilo;
- los tramos de los chips, las marcas de lista y el texto de los diseños (titulillos, folios, bandas de apertura y de parte);
- el texto del pie, de la nota y de las celdas de tabla de cada recurso;
- las variantes que nombra el
<text>de una figura SVG, al incrustarla. Si el proveedor no puede servir ninguna variante de una familia, se pasa a la siguiente familia de la listafont-familydel SVG.
Así, nunca se pide la cursiva de una familia de encabezados que nadie inclina, y una figura sin nota no necesita las variantes de la nota.
Cuando el proveedor rechaza una variante, el render sigue adelante. Se incrusta en su lugar otra variante de la misma familia y se emite un PdfWarning. La sustituta es la primera variante que carga, probando los nueve pesos estándar (de 100 a 900) en el orden que usa la selección de fuentes de CSS, que es también la variante que el navegador muestra en la previsualización:
- primero el mismo estilo. Para un peso entre 400 y 500, van primero los pesos hasta 500, después los más ligeros, del más cercano hacia abajo, y después los más gruesos, de 600 hacia arriba. Para un peso por debajo de 400, van primero los más ligeros, del más cercano hacia abajo, y después los más gruesos. Para un peso por encima de 500, van primero los más gruesos y después los más ligeros;
- después el otro estilo, cursiva para la redonda y redonda para la cursiva, en el peso pedido y en los demás pesos con el mismo orden.
Por eso una familia sin cursivas compone sus tramos en cursiva en redonda, una familia que solo publica 400 y 700 toma un 600 como 700, y una familia que publica un único corte lo compone todo con él. Al proveedor se le pide una variante cada vez, en ese orden, y nunca dos veces la misma, así que nunca se incrusta una variante que nada usa. A una familia que el proveedor no puede servir en absoluto se le piden esas 18 variantes antes de que el render falle.
const bytes = await renderToPdf(doc, {
fontProvider,
onWarning: (w) => {
// { kind: 'fontFallback', family: 'Oswald', weight: 700, style: 'italic',
// fallback: { weight: 700, style: 'normal' }, reason: '…', message: '…' }
console.info(w.message);
},
});Sin onWarning, el mensaje va a console.warn. El texto conserva sus posiciones, que vienen del VDT y se midieron con las variantes que tenía el navegador, así que una sustituta con otros anchos puede verse apretada o floja. Para corregirlo, sirve la variante real. Un render solo falla (postext-pdf: failed to load font(s): …) cuando el proveedor no puede servir ninguna variante de una familia en un peso estándar, ni redonda ni cursiva.
#Proveedor de fuentes en el navegador (Fontsource + WOFF2)
El sandbox distribuye createPdfFontProvider() (packages/postext-sandbox/src/viewport/pdfFontProvider.ts), que puedes copiar a cualquier app de navegador. Lo esencial:
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
const bytesCache = new Map<string, Promise<Uint8Array>>();
function fontsourceId(family: string): string {
return family.toLowerCase().replace(/\s+/g, '-');
}
function fontsourceWoff2Url(
family: string,
weight: number,
style: 'normal' | 'italic',
): string {
const id = fontsourceId(family);
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
}
export function createPdfFontProvider(): PdfFontProvider {
return async (family, weight, style) => {
const key = `${family}|${weight}|${style}`;
const cached = bytesCache.get(key);
if (cached) return cached;
const promise = (async (): Promise<Uint8Array> => {
const url = fontsourceWoff2Url(family, weight, style);
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
// pdf-lib necesita bytes TTF, así que descomprimimos el envoltorio WOFF2 en el cliente.
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
})();
bytesCache.set(key, promise);
return promise;
};
}Una versión de producción debería además:
- Consultar los pesos disponibles (vía
https://api.fontsource.org/v1/fonts/{id}) y ajustar el peso pedido al más cercano que la familia realmente distribuye, para que una petición deweight: 600sobre una familia que solo tiene{400, 700}siga funcionando. - Caer de italic a normal cuando una familia no tenga cortada la itálica para el peso solicitado, en lugar de fallar todo el render.
- Reutilizar la caché entre renders (mantén
bytesCachea nivel de módulo, no por llamada) para que regenerar el PDF tras un cambio de configuración sea prácticamente gratis.
#Fuentes chinas, japonesas y coreanas
Una fuente CJK no viene en un solo archivo pequeño. Fontsource distribuye Noto Serif SC en un centenar de archivos por peso, cada uno con una parte de los caracteres y declarado en la hoja de estilos de la familia con su unicode-range (@fontsource/noto-serif-sc/400.css); el navegador descarga los archivos que toca el texto de la página. El archivo latin que pide el proveedor de arriba no tiene ni un solo carácter han, y los subconjuntos con nombre están incompletos: al chinese-simplified de Noto Serif SC le falta 釵, y el chinese-traditional de Noto Serif TC no tiene ninguno de los signos de ancho completo (),!?:;.
Por eso un proveedor puede responder a una variante con varios archivos. renderToPdf le pasa los caracteres que las páginas componen en esa variante (request.codePoints), reunidos de todos los capítulos antes de dibujar nada, y el proveedor devuelve los archivos que los contienen, en el orden en que el navegador los consulta. Cada archivo se incrusta como un subconjunto propio y cada carácter se dibuja con el primer archivo que tiene glifo para él: un capítulo que toca 60 fragmentos incrusta 60 subconjuntos pequeños. Si se vuelve a pedir la variante, por ejemplo para el texto de una figura SVG, solo se piden los caracteres que faltan en sus archivos. Un proveedor que devuelve un solo Uint8Array funciona como antes. El proveedor del sandbox lee la hoja de estilos de Fontsource del peso y el estilo y descarga los archivos cuyos intervalos contienen el texto; su núcleo:
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
type Slice = { url: string; ranges: Array<[number, number]> };
async function fontsourceSlices(family: string, weight: number, style: 'normal' | 'italic'): Promise<Slice[]> {
const id = family.toLowerCase().replace(/\s+/g, '-');
const cssUrl = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
const css = await (await fetch(cssUrl)).text();
return [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => ({
url: new URL(/url\(\.?\/?([^)]+\.woff2)\)/.exec(rule)![1], cssUrl).href,
ranges: /unicode-range:\s*([^;]+);/.exec(rule)![1].split(',').map((part) => {
const [lo, hi = lo] = part.trim().slice(2).split('-');
return [parseInt(lo, 16), parseInt(hi, 16)] as [number, number];
}),
}));
}
export const sliceFontProvider: PdfFontProvider = async (family, weight, style, request) => {
// Where ranges overlap, the browser tries the last rule first.
const slices = (await fontsourceSlices(family, weight, style)).reverse();
const picked = new Set<Slice>();
for (const cp of request?.codePoints ?? []) {
const slice = slices.find((s) => s.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
if (slice) picked.add(slice);
}
if (picked.size === 0) picked.add(slices[0]!);
return Promise.all(slices.filter((s) => picked.has(s)).map(async (s) =>
decompressWoff2(new Uint8Array(await (await fetch(s.url)).arrayBuffer()))));
};Una familia latina pasa por el mismo código: un texto en inglés recibe solo su archivo latin, uno en checo recibe latin y latin-ext.
Los caracteres para los que ningún archivo de la variante tiene glifo se dibujan con el glifo .notdef de la fuente (una caja vacía en la mayoría), y renderToPdf los notifica una vez por variante cuando termina de dibujar las páginas:
// { kind: 'missingGlyph', family: 'Noto Serif TC', weight: 400, style: 'normal',
// characters: [',', '!', '?'], message: '…' }El Sandbox muestra estos avisos, y los dos siguientes, en su panel Revisión tras cada PDF que genera. Si cambian el libro, sus ajustes o sus recursos, se marcan como de un PDF anterior hasta que el siguiente los sustituye; abrir otro libro los retira.
- La negrita necesita un archivo estático por peso. Fontsource sirve cada peso de Noto Serif SC y TC en archivos estáticos propios, así que la negrita funciona en el Sandbox. Los archivos de Google Fonts (
NotoSerifSC[wght].ttf, 25 MB) son fuentes variables: pdf-lib incrusta su instancia por defecto, así que una variante 700 se imprime con peso 400, yrenderToPdflo notifica comovariableFontDefaultInstance. Para un paquete, saca una instancia estática por peso con fontTools (fonttools varLib.instancer NotoSerifSC[wght].ttf wght=700) y redúcela a los caracteres del libro conpyftsubset. - Usa las versiones TrueType. Source Han Serif y los archivos
.otfde Noto Serif CJK tienen contornos CFF, que postext-pdf incrusta enteros, de 8 a 25 MB por peso; una fuente CFF de más de 2 MB se notifica comocffEmbeddedWhole. Las versiones TrueType (Google Fonts, Fontsource) se reducen a los glifos usados. Para el japonés, Noto Serif JP y Noto Sans JP (Google Fonts, o los fragmentos numerados de Fontsource), Shippori Mincho, Zen Old Mincho y BIZ UDMincho vienen en TrueType; Source Han Serif JP y los archivos.otfJPde Noto Serif CJK son CFF. - Formas japonesas de una fuente panCJK. Un mismo punto de código de un carácter han, de la puntuación o de las comillas puede dibujarse de una manera en Japón y de otra en China, y una fuente panCJK (Source Han, Noto CJK) tiene las dos. El PDF compone un documento japonés (
locale: 'ja'), y un aislado en japonés (:ltr[…]{lang=ja}) en cualquier documento, con el sistema de idioma OpenTypeJAN, de modo que la funciónloclde la fuente imprime las formas japonesas que el lienzo y el HTML imprimen gracias alang. Un aislado en otro idioma dentro de un libro japonés se compone con las formas de ese idioma (las de la fuente por defecto, para el chino) y se etiqueta como unSpancon su/Lang. Los documentos chinos y los demás se componen con las formas por defecto de la fuente, como hasta ahora. Una fuente hecha para el japonés, como Noto Serif JP, ya tiene formas japonesas por defecto; aun así pone las “ ” del texto japonés en su formaJAN.
Un carácter que falta en una familia no se toma de otra: Noto Serif TC no toma prestado de Noto Serif SC. Resuelve la cobertura al preparar los archivos de fuente; el ejemplo de 红楼梦 copia en sus subconjuntos TC los glifos que les faltan desde la fuente SC.
#Proveedor de fuentes en el servidor (Node, archivos locales)
En Node puedes saltarte por completo el paso WOFF2 y leer archivos TTF/OTF desde disco:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { PdfFontProvider } from 'postext-pdf';
const FONT_DIR = '/ruta/a/fuentes';
function filename(family: string, weight: number, style: 'normal' | 'italic'): string {
const slug = family.replace(/\s+/g, '');
const styleSuffix = style === 'italic' ? 'Italic' : '';
const weightName =
weight >= 700 ? 'Bold'
: weight >= 600 ? 'SemiBold'
: weight >= 500 ? 'Medium'
: weight >= 300 ? 'Light'
: 'Regular';
return `${slug}-${weightName}${styleSuffix}.ttf`;
}
export const localFontProvider: PdfFontProvider = async (family, weight, style) => {
const buf = await readFile(join(FONT_DIR, filename(family, weight, style)));
return new Uint8Array(buf);
};#Bytes de recursos y másteres de impresión
resourceBytes(fileId) devuelve los bytes crudos de una imagen, y el backend los identifica por su contenido:
- PNG, JPEG, GIF y WebP se incrustan como imágenes;
- el marcado SVG se dibuja como trazados vectoriales, con su
textcompuesto como texto real con las fuentes incrustadas del documento, o se rasteriza a 600 ppp en el navegador cuando usa funciones que quedan fuera del subconjunto vectorial. Un<style>que solo contiene reglas@font-face(variantes que incrustó el autor) se omite y la figura sigue siendo vectorial (desde postext-pdf 1.25); cualquier otra hoja de estilo la convierte en ráster, hecho con las variantes que nombra su texto incrustadas desdefontProvider(salvo quediagramStyle.inlineFontso elsvg.inlineFontsdel recurso seafalse); - de un PDF se incrusta tal cual su primera página, como un XObject de formulario.
Cada imagen se guarda una sola vez en el archivo, aunque se dibuje muchas veces. Un SVG dibujado con trazados vectoriales se convierte en un XObject de formulario que pinta cada página, así que un marco o un logotipo en el diseño de página de un documento de treinta páginas se escribe una vez y no treinta; cada página más añade unos cientos de bytes. Hasta postext-pdf 1.4 cada página llevaba su propia copia de los trazados.
Una figura SVG puede nombrar un máster de impresión en svg.pdfFileId: un PDF de una sola página con la misma figura, normalmente el original del que se exportó el SVG. renderToPdf pide primero a resourceBytes el identificador del máster. Incrusta esa página en lugar del SVG, con sus fuentes, degradados y espacios de color intactos, allí donde se dibuje el SVG: como figura, como imagen de una celda de tabla (TableCell.image), como imagen de diseño o como icono de una caja (también su marker). El VDT lleva el identificador del máster en cada uno de esos usos (svg.pdfFileId en el recurso de la figura, pdfFileId en la imagen de una celda y en un bloque de imagen de diseño), así que en un libro todos los capítulos reciben el máster. Los backends canvas y HTML siguen dibujando el SVG. Se usan en cambio los bytes del propio SVG en tres casos: falta el máster, no es un PDF, o la tinta única está activa (diagramStyle.singleInk solo recolorea marcado SVG).
const resources: Resource[] = [{
id: 'mapa', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'mapa.svg', width: 800, height: 600, pdfFileId: 'mapa.pdf' },
}];
const archivos = new Map([['mapa.svg', bytesSvg], ['mapa.pdf', bytesMasterPdf]]);
const pdf = await renderToPdf(buildDocument({ markdown, resources }, config), {
fontProvider,
resourceBytes: (fileId) => archivos.get(fileId),
});Un anfitrión también puede devolver los bytes del máster para el identificador del propio SVG, como hace bundleResourceBytes; las dos formas funcionan.
#Texto vertical en el PDF
Una página vertical (layout.writingMode: 'vertical-rl') se dibuja a través de un marco girado un cuarto de vuelta, como la pinta el canvas, y su texto se compone columna abajo:
- Los caracteres de pie se muestran con una segunda fuente Type0 del mismo archivo incrustado: la misma CIDFont, los mismos anchos y el mismo mapa ToUnicode, con
Encoding /Identity-V(modo vertical). Un tramo de caracteres es un solo objeto de texto cuyos glifos bajan un cuadratín por la columna por sí mismos (DW2 [880 −1000]), así que los lectores seleccionan y extraen una columna como una línea. Los glifos se conforman convertyfwidde OpenType, que dan su forma vertical a los paréntesis, las comillas, los signos de pausa de China continental, los puntos suspensivos y las rayas; un carácter que queda de pie tal cual conserva su glifo horizontal. Nada de la fuente se incrusta dos veces. - Las palabras latinas y los números largos van tumbados con la fuente horizontal; un número en una casilla queda de pie, estrechado hasta el cuadratín si es más ancho; un signo sin forma vertical en la fuente se gira o se desplaza, como en el canvas.
- El espaciado entre caracteres se escribe como números de
TJ, que en modo vertical bajan el punto de escritura por la columna. - Cada línea vertical lleva un
/ActualTextcon su texto, de modo que copiar y extraer el texto la leen tal como se escribió.pdftotexty pdf.js leen las columnas de arriba abajo y de derecha a izquierda; pdf.js empieza una línea nueva en un número compuesto en una casilla. - Los enlaces, los marcadores y los destinos se llevan al pliego: un enlace sobre una línea vertical es un rectángulo alto y estrecho, y un marcador abre la página en lo alto de la columna de su encabezado.
- Un PDF etiquetado declara el sentido de escritura en su elemento
Document(el atributo de maquetaciónWritingMode /TbRl, que heredan todos los elementos); la validación PDF/UA-1 (veraPDF) pasa en un capítulo vertical. - Visores: Acrobat, Vista Previa, Chrome (PDFium), pdf.js y Poppler pintan las fuentes verticales. Un libro encuadernado por la derecha (
page.binding) pide además a los visores que muestren los pliegos de derecha a izquierda (/Direction /R2L,/PageLayout /TwoPageRight); Acrobat y Foxit lo siguen, Chrome no.
Un capítulo de 43 páginas compuesto en Noto Serif TC (los caracteres del libro, TrueType) ocupa unos 820 KB, casi todo de los dos subconjuntos de la fuente.
#Enlaces en el PDF
Las palabras de un enlace Markdown (consulta Formato del documento › Enlaces) se convierten en anotaciones de enlace URI, una por cada tramo de palabras enlazadas de una línea. Cada anotación cubre la caja de la línea y no lleva borde. En un render accesible, cada tramo es un elemento Link cuyo /Contents es su texto. Solo se enlazan destinos absolutos http:, https:, mailto:, tel: y ftp:, porque una URL relativa no tiene base dentro de un PDF. Los caracteres que quedan fuera del ASCII imprimible se codifican con porcentajes. Las citas :ref y las filas del índice conservan sus enlaces internos al documento.
#Ejemplo completo en el navegador: componer, renderizar, descargar
Juntándolo todo — construir el VDT, renderizar a PDF y disparar la descarga desde el navegador:
import { buildDocument, createMeasurementCache } from 'postext';
import { renderToPdf } from 'postext-pdf';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function downloadPdf(markdown: string, config: PostextConfig) {
const cache = createMeasurementCache();
const vdt = buildDocument({ markdown }, config, cache);
const bytes = await renderToPdf(vdt, { fontProvider });
const blob = new Blob([bytes.slice().buffer], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}Importante: llama a ensureConfigFontsLoaded(config) (o equivalente) antes de buildDocument cuando tu configuración referencie web fonts. La maquetación se mide contra las métricas de fuente que el navegador tenga en ese momento para esa familia — si la fuente real aún no ha llegado, el VDT se mide contra una de reserva y el PDF no coincidirá con la salida de canvas o HTML. El sandbox lo hace explícitamente antes de cada render (ver packages/postext-sandbox/src/viewport/PdfViewport.tsx).
#Ejemplo en vivo: un PDF en el navegador
El flujo completo de arriba, ejecutándose en el navegador: el pen importa postext y postext-pdf desde un CDN, carga las fuentes web, construye el documento, incrusta los cortes de Fontsource a través del proveedor de fuentes y entrega los bytes a un enlace que abre el archivo en una pestaña nueva y a otro de descarga. El PDF resultante tiene los mismos saltos de línea que la salida en canvas y HTML, fuentes incrustadas reales y marcadores de esquema.
import { buildDocument } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
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.`;
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// The PDF embeds real font files. Fontsource publishes one static WOFF2 per
// weight and style; decompress it to the TTF bytes pdf-lib can embed.
const fontProvider = async (family, weight, style) => {
const id = family.toLowerCase().replace(/\s+/g, '-');
const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
const res = await fetch(url);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
};
// Layout is measured with the browser's fonts, so load them before building:
// otherwise the PDF would not match the canvas or HTML output.
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);
// Same VDT, now translated to PDF points: identical line breaks and placement.
const bytes = await renderToPdf(doc, { fontProvider });
// A PDF viewer cannot run inside this sandboxed result frame,
// so hand the file to a new tab and to a download link.
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(bytes.length / 1024).toFixed(0)} KB PDF`;index.html
<p id="status">Rendering…</p>
<p id="links" hidden>
<a id="open" target="_blank" rel="noopener">Open lantern.pdf in a new tab</a> ·
<a id="download" download="lantern.pdf">Download it</a>
</p>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
}Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.
#Renderizar el PDF en un worker
postext-pdf/worker saca renderToPdf del hilo principal. El worker escribe el texto, las figuras vectoriales, el árbol de estructura y el propio archivo. Hay dos tareas que necesitan la página, así que el worker se las pide al hilo principal: descargar las fuentes y rasterizar un SVG a través de un <img>. En un libro de cientos de páginas el render tarda segundos que, si no, congelarían la página; para unas pocas páginas, llamar directamente a renderToPdf es más sencillo.
import { createPdfWorker } from 'postext-pdf/worker';
const pdfWorker = createPdfWorker();
const bytes = await pdfWorker.render(docs, {
fontProvider, // se ejecuta en este hilo
resourceBytes: new Map([['mapa.svg', bytesSvg]]), // un Map; sus buffers pasan al worker
onProgress: ({ phase, pages, totalPages }) => mostrarProgreso(phase, pages, totalPages),
onWarning: (w) => console.info(w.message),
});
pdfWorker.dispose();render(docs, options)acepta un documento o la lista de documentos de los capítulos de un libro. Acepta las opciones derenderToPdf, con dos diferencias.resourceByteses unMap<string, Uint8Array>cuyos buffers se transfieren, así que pasa copias de los bytes que quieras conservar.rasterizeSvg, si se indica, se ejecuta en el hilo principal; por defecto lo hacen elImagey el canvas de la propia página.- Un handle renderiza un documento cada vez.
dispose()termina el worker y rechaza cualquier render que siga pendiente. createPdfWorker({ worker })acepta unWorkerque crees tú, para las herramientas de build que controlan las URL de los workers. Ese worker debe ejecutarpostext-pdf/worker/entry.
Desde un CDN. Por defecto, el script del worker se carga desde la URL del propio paquete (new URL('./pdf.worker.js', import.meta.url)). Una página de otro origen puede no poder arrancarlo: importado desde esm.sh, createPdfWorker() lanza Failed to construct 'Worker': Script at 'https://esm.sh/postext-pdf@…/pdf.worker.js' cannot be accessed from origin …. Arranca en su lugar un worker de módulo del mismo origen que importe la entrada (si fijas una versión, fija la misma en las dos URL):
import { createPdfWorker } from 'https://esm.sh/postext-pdf/worker';
const entrada = URL.createObjectURL(new Blob(
["import 'https://esm.sh/postext-pdf/worker/entry';"],
{ type: 'text/javascript' },
));
const pdfWorker = createPdfWorker({ worker: new Worker(entrada, { type: 'module' }) });El worker de composición de postext/worker necesita el mismo envoltorio alrededor de postext/worker/entry (consulta Ejecutar la composición en un Web Worker).
#PDFs listos para imprenta
Para flujos de trabajo de impresión en producción, ajusta estas opciones de configuración antes de renderizar:
page.cutLines.enabled: true— añade área de sangrado y marcas de corte alrededor del trim, y da a cada página una TrimBox y una BleedBox. Ver Marcas de corte.print: { standard: 'pdfx4', outputProfile: 'fogra51' }(o'pdfx1a') — un archivo PDF/X con la condición de salida, la identificación y las cajas que revisa una imprenta; cada color y cada imagen RGB se separan con el perfil ICC, el 100 % K sobreimprime y las masas negras grandes salen en negro enriquecido. Ver Producción para imprenta (configuración).colorSpace: 'cmyk'(opdfGeneration: { forceColorSpace: true, colorSpace: 'cmyk' }) — la misma separación sin la identificación PDF/X (las marcas de corte van siempre en color de registro). Los originales de impresión en PDF se insertan tal como son.page.dpi: 300— los px por pulgada de la maquetación: un mapa de bits sin resolución propia se imprime a esta resolución a su tamaño natural. El preflight avisa de las imágenes por debajo de 300 ppp a su tamaño impreso.ColorValue.cmyk— un color definido en CMYK se imprime con sus valores exactos.{ pageNegative: true }enRenderToPdfOptions— invierte el área de trim usando un blend mode de tipo Difference (las marcas de corte quedan sin invertir). Útil para comprobaciones de preflight sobre tipografía oscura-sobre-claro.
#Implementación de referencia
El componente PdfViewport del sandbox (packages/postext-sandbox/src/viewport/PdfViewport.tsx) conecta las piezas anteriores en una previsualización en vivo con botones de regenerar, descargar e imprimir, y es un buen punto de partida para cualquier integración PDF en el navegador. Construye el VDT a través del worker de layout compartido (consulta Ejecutar la composición en un Web Worker) para que pulsar Regenerar no congele la UI mientras se ejecuta el pipeline — el hilo principal solo se encarga de renderToPdf (que ya es rápido una vez existe el VDT).
#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.
#Paquetes (archivos .postext)
Un archivo .postext es un libro entero en un solo archivo: un zip con un manifiesto preset.json, un archivo markdown por capítulo, los datos de los recursos (mapas de bits, SVG, másters PDF de impresión) y los archivos de las fuentes que nombra la configuración. El Sandbox lo exporta e importa y el skill para agentes lo entrega. El paquete postext también sabe crearlo y abrirlo, así que un libro puede pasar de esas herramientas a tu propio programa, y al revés, sin perder nada.
mi-libro.postext
├── preset.json manifiesto: nombre, idioma, capítulos, configuración, recursos, fuentes
├── chapters/01-anochecer.md
├── chapters/02-noche.md
├── resources/farol.svg
└── fonts/ebgaramond-400-normal.woff2
El manifiesto se describe campo a campo en el apéndice Formato de paquete de preset del Sandbox. Un archivo puede llevar además layouts.json, el recuento de páginas del Sandbox, para que el libro se abra ya paginado allí, o uno por edición de un libro en varios idiomas (layouts.zh-Hant.json, que se lee primero). openBundle los ignora.
La API se exporta desde postext y desde el subpath postext/bundle, que añade las utilidades de bajo nivel. Importa desde postext cuando además vayas a renderizar. Así los adaptadores de paquetes y los renderizadores comparten una sola instancia del módulo, lo que importa en un CDN como esm.sh, donde cada punto de entrada es un build distinto.
#Abrir un paquete
openBundle recibe los bytes del archivo (un Uint8Array, un ArrayBuffer, o un Blob / File de un <input type="file">) y devuelve todo lo que necesitan el motor y sus backends:
import { openBundle } from 'postext';
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
bundle.chapters; // [{ title, file, markdown }, …] en el orden del libro
bundle.config; // PostextConfig, lista para buildDocument
bundle.resources; // Resource[]
bundle.files; // Map<ruta, Uint8Array>: todos los archivos del paquete| Campo | Qué contiene |
|---|---|
manifest | El preset.json ya validado. |
id, name, description | Tomados del manifiesto. |
locale, locales | El idioma en que se leyó el contenido, y todos los idiomas que trae un paquete bilingüe. options.locale elige uno: primero la etiqueta exacta, luego el idioma base, luego el idioma propio del paquete. |
chapters | { title, file, markdown } por capítulo. Un capítulo sin título en el manifiesto toma el texto de su primer encabezado #. |
config | La paleta de colores por defecto y los tipos de recurso en el idioma del paquete, después la config del manifiesto y después los ajustes propios del idioma. El idioma del paquete es el locale de arriba, así que un paquete en un solo idioma recibe sus propios rótulos, pida lo que pida options.locale. Un manifiesto que no nombra ningún idioma toma el que fija su config (locale y, si falta, el de la separación silábica) y, si tampoco lo hay, options.locale. customFonts lista las familias tipográficas del paquete. Es la misma configuración con la que el Sandbox abre el paquete. |
resources | Los recursos, con los pies del idioma elegido. Un tamaño que falte en el manifiesto se lee del archivo. |
fonts | Una entrada por variante: { family, weight, style, format, file, bytes }. |
files | Todos los archivos del zip, por su ruta. |
thumbnail, canvasScope | La ruta de la portada, y cómo pide el paquete que se muestre: la view del manifiesto, con la localized[…].view del idioma servido por encima. |
start | Dónde empieza el libro, para un paquete que contiene parte de uno más largo: el start del manifiesto, o el localized[…].start del idioma servido. Falta en un libro que empieza por su principio. |
warnings | Problemas que no impiden abrirlo: un archivo de fuente no admitido, un máster de impresión que falta. |
El fileId de cada archivo es su ruta dentro del paquete. resource.svg.fileId, resource.bitmap.fileId y el fileId de cada variante de customFonts se buscan directamente en bundle.files. openBundle lanza un error si los bytes no son un zip, si no hay un preset.json válido (en la raíz o bajo una única carpeta) o si falta un archivo que el manifiesto nombra.
Paquetes escritos por postext 1.4 o anterior
Todo manifiesto que escriben createBundle y el Sandbox lleva configVersion: 11: las reglas de configuración para las que se escribió su config. Un manifiesto sin él lo escribió postext 1.4 o anterior, que resolvía dieciocho cosas de otra manera:
- Los saltos de encabezado (reglas 3): hasta la 1.4, un objeto
headingssin salto de H1 no tenía ninguno (ver Configuración por nivel). - El tamaño de las fórmulas (reglas 4): hasta la 1.4, las fórmulas salían 1,131 veces más grandes de lo que dice
fontSizeScale(ver Tamaño de las fórmulas). - El espacio bajo un recurso en línea (reglas 5): hasta la 1.4, el texto que sigue a una figura o tabla con
placement.position: 'here'continuaba en la siguiente línea de la rejilla, sin el hueco de los flotantes debajo (verlayout.inlineResourceGapen Disposición). - El espacio en torno a un recurso en línea dentro de un recuadro (reglas 6): hasta la 1.4, ese recurso quedaba pegado al texto del recuadro que lo rodea (ver
layout.inlineResourceGapInBoxesen Disposición). - Las marcas en línea de los encabezados (reglas 6): hasta la 1.4, un encabezado imprimía las palabras de su
*cursiva*, su**negrita**y sus otras marcas con su propio estilo, sin marcas (verheadings.inlineMarksen Encabezados). - El tamaño de una letra capital (reglas 6): hasta la 1.4, el
dropCapde un texto de diseño sinfontSizeera tan alto como todas las cajas de línea que abarca, con la parte alta por encima de la primera línea (verdropCapen Elementos de texto). - El sitio bajo una línea de dos puntos (reglas 6): hasta la 1.4,
keepColonWithListdaba por bueno para la lista una línea de sitio bajo la línea que termina en dos puntos, y un primer elemento de dos líneas que las reglas de huérfanas y viudas mantienen entero pasaba a la columna siguiente sin ella (verbodyText.colonListRoom). - Las líneas que deja el corte de una caja (reglas 6): hasta la 1.4, una caja que se partía dentro de un párrafo o de un elemento de lista podía dejar una sola línea suya a un lado, siempre que cada lado de la caja sumara sus
splitMinLineslíneas (verlayout.boxChildSplitMinLinesen Disposición). - Los cortes de línea tras una raya (reglas 7): hasta la 1.4, Knuth-Plass nunca terminaba una línea tras una raya o una semirraya puesta entre dos palabras sin espacios (
Madrid–Barcelona,I.—Que trata), y el divisor línea a línea del texto con formato solo entre dos letras (verbodyText.breakAfterDashesen Texto de cuerpo). - El texto en bandera (reglas 7): hasta la 1.4, el texto corrido en bandera se componía línea a línea, llenando cada línea antes de pasar a la siguiente, dijera lo que dijera
optimalLineBreaking(verbodyText.optimalRaggeden Texto de cuerpo). - La división bajo un encabezado (reglas 8): hasta la 1.4, el párrafo que sigue a un encabezado al pie de una columna conservaba allí todas las líneas que cupieran, aunque pasaran muy pocas a la columna siguiente (ver
headings.keepWithNextSpliten Encabezados). - El espacio bajo un contenedor
:::paragraphs(reglas 8): hasta la 1.4, el espacio del estilo se ponía bajo el último párrafo antes del ajuste a la rejilla, el espacio del bloque siguiente (elmarginTopde un encabezado) se sumaba debajo y la separación entre párrafos del texto no contaba (verbodyText.paragraphContainerSpacingen Texto de cuerpo). - Los cortes de línea tras el guion de un compuesto (reglas 8): hasta la 1.4, Knuth-Plass nunca terminaba una línea justificada tras un guion entre dos letras (
físico-química) en un párrafo sin formato en línea, y sí lo hacía en uno con formato (verbodyText.breakAfterHyphensen Texto de cuerpo). - Los poemas sin separador (reglas 9): hasta la 1.22, un poema
:::versecuyas líneas no llevaban||se componía en hemistiquios sueltos, cada línea centrada (véasebodyText.verse.layouten Verso). - Una sangría de primera línea junto a una francesa (reglas 9): hasta la 1.22, la
hangingIndentde un estilo de párrafo sustituía a sufirstLineIndent, y la primera línea empezaba enindent(véase Estilos de párrafo). - Una barra invertida al final de una línea (reglas 9): hasta la 1.22, una barra invertida al final de una línea de un párrafo, una cita o un elemento de lista, y
\\ante un espacio, se imprimían, y las líneas se unían con un espacio (verbodyText.hardLineBreaksen Texto de cuerpo). - Las vallas de código (reglas 9): hasta la 1.22, una valla
```o~~~y las líneas que encierra se leían como Markdown: las líneas se unían en párrafos, una línea con#se convertía en encabezado y las vallas se imprimían (vercodeStyle.blocksen Listados de código). - Los versos partidos (reglas 10): en la 1.23, un verso de un poema compuesto verso a verso más ancho que la caja se partía con su espaciado natural, por poco que sobrara (ver
bodyText.verse.tightenen Verso).
openBundle y readBundle leen el config de un manifiesto así, y la configuración de cada idioma en localized, a través de migrateConfig, que escribe los saltos tal como los componía la 1.4 y multiplica la escala de las fórmulas por 1,131 (y divide por ese factor los márgenes de las fórmulas en bloque dados en em). A un manifiesto marcado con 3 a 7, que escribió una versión preliminar de la 1.5, solo se le aplican las fijaciones de las reglas posteriores a su marca: con 3, el tamaño de las fórmulas, el hueco en línea, las cinco fijaciones de las reglas 6, las dos de las reglas 7 y las tres de las reglas 8; con 4, el hueco en línea y las de las reglas 6, 7 y 8; con 5, las de las reglas 6, 7 y 8; con 6, las de las reglas 7 y 8; con 7, solo las de las reglas 8. La división bajo un encabezado (pinLegacyHeadingSplit) se escribe como headings.keepWithNextSplit: 'fill' sobre el headings que dejan en vigor las capas, cuando algún capítulo leído tiene un encabezado y la configuración no nombra un valor propio, mantiene activado headings.keepWithNext y no desactiva bodyText.avoidOrphans. Los cortes tras el guion de un compuesto (pinLegacyHyphenBreaks) se escriben como bodyText.breakAfterHyphens: false sobre el bodyText en vigor cuando un capítulo leído tiene un guion entre dos letras y la configuración ni lo fija ya ni desactiva optimalLineBreaking. El espacio bajo los contenedores (pinLegacyParagraphContainerSpacing) se escribe como bodyText.paragraphContainerSpacing: 'add' sobre el bodyText en vigor cuando la configuración declara algún estilo de párrafo (en paragraphStyles o en los ajustes propios del visor HTML), un capítulo leído abre un contenedor :::paragraphs en una línea propia y la configuración no lo fija ya. Los cortes tras una raya (pinLegacyDashBreaks) se escriben como bodyText.breakAfterDashes: false sobre el bodyText en vigor cuando un capítulo leído tiene una raya o una semirraya entre palabras sin espacios (una letra, una cifra o un signo de cierre delante, y una letra, una cifra o un signo de apertura detrás; unas comillas delante de la raya cuentan cuando las precede una letra, una cifra, un signo de cierre o un espacio de no separación, como en «no»—y, no en dijo "—Hola; una marca en línea pegada a la raya o a las comillas, como los ** de **I.**—Que, cuenta a cualquiera de los dos lados) y la configuración no lo fija ya. La composición del texto en bandera (pinLegacyRaggedBreaking) se escribe como bodyText.optimalRagged: false sobre el bodyText en vigor cuando la configuración pone en bandera algún texto corrido (un textAlign distinto de 'justify' en el texto de cuerpo, un estilo de párrafo, el cuerpo de un recuadro, el de una parte o el de un estilo de sección (headingStyles[].bodyStyle), o en los ajustes propios del visor HTML), no lo fija ya y no desactiva optimalLineBreaking. El hueco en los recuadros (pinLegacyBoxResourceGap) se escribe como layout.inlineResourceGapInBoxes: false sobre el layout en vigor cuando un recurso se inserta en su propia línea dentro de un :::callout de los capítulos leídos y la configuración no lo fija ya. El corte de las cajas (pinLegacyBoxChildCut) se escribe como layout.boxChildSplitMinLines: 1 sobre el layout en vigor cuando un capítulo leído abre un :::callout en una línea propia y la configuración no lo fija ya. Las marcas de los encabezados (pinLegacyHeadingMarks) se escriben como headings.inlineMarks: false sobre el headings que dejan en vigor las capas, cuando algún encabezado de los capítulos leídos lleva una marca (*, _, ^, ~, :smallcaps[ o un enlace en su título) y la configuración no nombra un valor propio. A las letras capitales (pinLegacyDropCapSize) se les escribe su tamaño de la 1.4 como dropCap.fontSize, estén donde estén: en la unidad del interlineado del elemento cuando es una longitud, y si no en la unidad de su cuerpo. El sitio bajo los dos puntos (pinLegacyColonListRoom) se escribe como bodyText.colonListRoom: 'line' sobre el bodyText en vigor cuando una lista de los capítulos leídos sigue a una línea que termina en dos puntos (aunque haya líneas en blanco entre ellas) y la configuración ni nombra un sitio ni desactiva keepColonWithList. El hueco en línea (pinLegacyInlineGap) se escribe como layout.inlineResourceGap: 'above' sobre el layout que dejan en vigor las capas, cuando una línea de los capítulos leídos inserta un recurso (::resource{id="…"} sola en su línea, tal como la lee el analizador: una mención en el texto o en un fragmento de código no cuenta) y la configuración no nombra un hueco propio. El tamaño se fija sobre el math que dejan en vigor las capas (el math propio de un idioma sustituye al compartido), y solo cuando los capítulos leídos tienen algún $: un paquete sin fórmulas conserva su config tal como se escribió. Si ni el manifiesto ni el idioma dan un math, el que queda en vigor es el de la baseConfig de readBundle (la del lector), y también se fija, porque la 1.4 componía las fórmulas del paquete a ese tamaño: una base con fontSizeScale: 1.5 se lee como 1,5 × 1,1312. Los saltos de encabezado de la base se toman tal como vienen. Así un paquete antiguo conserva lo que componían esas reglas, y bundle.config muestra los saltos, el tamaño de las fórmulas, los huecos, las marcas de los encabezados, el tamaño de las letras capitales, el sitio bajo los dos puntos, el corte de las cajas, los cortes tras una raya, los cortes tras el guion de un compuesto, la composición del texto en bandera, la división bajo un encabezado y el espacio bajo los contenedores con los que se compone. Las correcciones de composición de la 1.5 no tienen fijación y se le aplican como a cualquier libro, así que una página a la que afecten aún puede moverse (la lista está en Tamaño de las fórmulas). Un preset.json escrito a mano para las reglas actuales pone "configVersion": 11; marcar así el manifiesto de un paquete antiguo es también la forma de leerlo, con una línea, con las reglas actuales (un paquete sin versión pierde entonces también la fijación de sus saltos de encabezado). Un manifiesto marcado 8, escrito por postext 1.5 a 1.22, recibe solo los cuatro fijados de las reglas 9, como también todos los anteriores: la composición del verso (pinLegacyVerseLayout) se escribe como bodyText.verse.layout: 'bayt' en el bodyText vigente, cuando un capítulo leído compone un poema :::verse cuya apertura no nombra composición y cuyas líneas no llevan separador de hemistiquios, y la configuración no lo fija ya; las sangrías emparejadas (pinLegacyPairedIndents) quitan la firstLineIndent de todo estilo de párrafo (en paragraphStyles o en los ajustes del visor HTML) que fija también una hangingIndent distinta de cero; los saltos de línea forzados (pinLegacyHardBreaks) se escriben como bodyText.hardLineBreaks: false en el bodyText vigente, cuando un capítulo leído termina una línea de un párrafo, una cita o un elemento de lista con una barra invertida y el bloque sigue debajo, o pone \\ ante un espacio y más texto (salvo el código en línea y las fórmulas, las fórmulas destacadas, los títulos y los poemas :::verse), y la configuración no lo fija ya; y las vallas de código (pinLegacyCodeBlocks) se escriben como codeStyle.blocks: false, cuando un capítulo leído abre una valla ``` o ~~~ (de tres o más caracteres, con hasta tres espacios delante) y la configuración no lo fija ya. Un manifiesto marcado 9, escrito por postext 1.23, recibe solo la fijación de las reglas 10, como también todos los anteriores: los versos partidos (pinLegacyVerseTightening) se escriben como bodyText.verse.tighten: false en el bodyText vigente, cuando un capítulo leído compone un poema verso a verso (una apertura :::verse que nombra layout=lines, o que no nombra composición sobre versos sin separador de hemistiquios mientras la configuración no componga esos poemas como bayts) y la configuración no lo fija ya. Un manifiesto marcado 10, escrito por postext 1.24, recibe solo la fijación de las reglas 11, como también todos los anteriores: el equilibrado de una retícula de caracteres (pinLegacyGridBalancing) se escribe como headings.balancing.enabled: true en el headings vigente, cuando la configuración combinada fija cjk.grid.enabled en texto horizontal y no fija ella misma enabled, porque la 1.24 equilibraba esas páginas por defecto. Las reglas 11 también impiden que un título de obra se parta tras un solo carácter, componen los números en círculo como caracteres chinos y el texto de diseño CJK con las reglas del cuerpo (#637): un manifiesto marcado 10 o anterior recibe cjk.titleMinChars: 1 (pinLegacyTitleBreaks) cuando un capítulo leído tiene un título (《, 〈 o :book[), cjk.circledNumbers: 'western' (pinLegacyCircledNumbers) cuando uno tiene un número en círculo (U+2460–U+24FF, U+2776–U+2793) y cjk.composeDesignText: false (pinLegacyDesignText) cuando un capítulo leído o la propia configuración tienen texto CJK; cada uno en el cjk vigente y solo si la configuración no lo fija ya. También cortan las tablas en línea y componen :::columns en el texto corrido (#634): un manifiesto marcado 10 o anterior recibe tableStyle.splitInline: false (pinLegacyInlineTableSplit) en el tableStyle vigente cuando un capítulo leído inserta un recurso, y layout.flowColumns: false (pinLegacyFlowColumns) en el layout vigente cuando un capítulo leído abre una valla :::columns en una línea propia, cada uno solo si la configuración no lo fija ya. También ofrecen a un flotante la cabeza de la columna de una apertura a ancho de página (#639): un manifiesto marcado 10 o anterior recibe layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) en el layout vigente cuando la configuración combinada define un nivel o un estilo de encabezado con span: 'page' y un capítulo leído tiene un encabezado, solo si la configuración no lo fija ya.
import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'Un libro sin fórmulas.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
// layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
// headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Texto.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nTexto.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'Te digo—y eso es todo.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verso' }] }, 7, { content: ':::paragraphs{style="verso"}\nUn verso.\n:::' });
// => { paragraphStyles: [{ id: 'verso' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'Un ejercicio teórico-práctico.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // reglas actuales: el propio `config`content es el markdown que compone la configuración (una cadena o una lista de capítulos). Sin él, los dos huecos, las marcas de los encabezados, el sitio bajo los dos puntos, el corte de las cajas, los cortes tras una raya, la división bajo un encabezado y los cortes tras el guion de un compuesto se fijan siempre, el tamaño de las fórmulas siempre que las matemáticas estén activadas y el espacio bajo los contenedores siempre que la configuración declare algún estilo de párrafo, porque el motor no puede saber si el libro tiene alguna fórmula, algún contenedor :::paragraphs, alguna figura en línea, algún encabezado con marcas, alguna lista introducida con dos puntos, alguna caja, alguna raya entre palabras, algún encabezado o algún compuesto. La composición del texto en bandera se fija solo según la configuración, haya contenido o no. Migra una configuración guardada una sola vez y vuelve a guardarla con CONFIG_VERSION: la fijación del tamaño multiplica la escala, así que una configuración migrada dos veces crecería dos veces. Sin él también se fijan la composición del verso y las vallas de código, porque el libro puede tener un poema :::verse sin separador o una valla, y las sangrías emparejadas se fijan solo por la configuración. También se fijan los versos partidos de la 1.23, porque el libro puede componer un poema verso a verso.
#Componer y renderizar un paquete
Cuatro utilidades conectan un paquete abierto con el motor y los backends:
loadBundleFonts(bundle)registra las fuentes del paquete endocument.fonts, y sus bytes en el registro de fuentes del motor para las imágenes SVG (registerFontBytes). Espérala antes de componer, porque la composición mide el texto con las fuentes que tiene el navegador. Las familias que el paquete nombra pero no incluye (Google Fonts) tienes que cargarlas tú, como en cualquier otro documento.registerBundleImages(bundle)decodifica las imágenes para el backend canvas (renderPage,renderToCanvas), portadas de vídeo incluidas.bundleImageUrl(bundle)es el resolvedorresourceImageUrlderenderToHtml, ybundleVideoUrl(bundle)su resolvedorresourceVideoUrlpara los archivos de vídeo que lleva un paquete. Las dos recolorean las figuras SVG cuandodiagramStyle.singleInkestá activo, una sola vez: recolorean el marcado y marcan las imágenes para que ningún backend vuelva a teñirlas (consulta Tinta única en canvas y en HTML). Las dos incrustan además en cada SVG las variantes que nombra su texto, primero desde las fuentes del propio paquete y después desde las variantes registradas en el motor (consulta Fuentes en el texto de los SVG);registerBundleImages(bundle, { onWarning })ybundleImageUrl(bundle, { onWarning })avisan de una familia sin variante.buildBundle(bundle)compone los capítulos en orden y devuelve unVDTDocumentpor capítulo. Cada capítulo continúa al anterior: contadores de encabezados y de recursos, la parte abierta, la paridad de página y la numeración. Un capítulo que imprime el índice de contenidos (:::toc) o el analítico (:::index) recibe el esquema del libro entero. Admite las mismas opciones quebuildDocument, másconfigpara sustituir la configuración del paquete,cachepara compartir una caché de medidas ymetadata(ver más abajo).bundleResourceBytes(bundle)ybundleFontProvider(bundle, { decodeWoff2, fallback })son las opcionesresourceBytesyfontProviderderenderToPdfdepostext-pdf. El proveedor de fuentes elige del paquete el peso más cercano del estilo pedido. Para una variante.woff2necesitadecompressWoff2, y para una familia que el paquete no incluye llama afallbackcon los argumentos del renderizador,requestincluido, y entrega lo que devuelva. Unfallbackque solo descarga el archivolatinde la familia imprime como cajas vacías una familia china que el paquete no incrusta; uno que responde con los fragmentos, comosliceFontProvideren Fuentes chinas, japonesas y coreanas, la imprime completa.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
const docs = buildBundle(bundle); // un VDTDocument por capítulo
const primeraPagina = renderPage(docs[0].pages[0], docs[0]); // un <canvas>
const pdf = await renderToPdf(docs, { // el libro entero
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});Para componer un solo capítulo por tu cuenta, pasa bundle.chapters[i].markdown, bundle.resources y bundle.config a buildDocument, como con cualquier documento.
Los metadatos del libro. Como en el Sandbox, el frontmatter del primer capítulo es el del libro: buildBundle entrega su title, su author y los demás a todos los capítulos, de modo que las cabeceras con {title} y {author} se mantienen en todas las páginas y el doc.metadata de cada capítulo los lleva. Un bloque de frontmatter al principio de un capítulo posterior se ignora: se localiza por sus líneas --- y se deja en blanco sin analizarlo, así que un YAML que el analizador rechazaría no causa ningún problema. options.metadata aporta valores que el frontmatter no fija (el frontmatter prevalece). El recuento de páginas del libro también llega a todos los capítulos: {bookTotalPages} lo imprime, mientras que {totalPages} cuenta el capítulo (consulta Recuento de páginas del libro).
const docs = buildBundle(bundle, { metadata: { author: 'A. Autora' } });
docs[3].metadata.title; // el `title:` del primer capítuloDónde empieza el libro. Un paquete puede contener parte de una publicación más larga: las páginas 58 a 61 de un número, el capítulo 4 de un libro de texto. Su start dice qué lo precede, en los términos de la continuation de buildDocument: las páginas anteriores a la primera (pageIndexOffset, que decide de qué lado cae la página 1 y, con él, los márgenes en espejo y las cabeceras de páginas pares e impares), la numeración de página vigente (pageNumbering), los contadores de encabezados (headings), la parte abierta (part) y los contadores de recursos, enunciados, notas al pie y líneas. createBundle lo escribe como start en preset.json, openBundle lo devuelve en bundle.start, y buildBundle compone con él el primer capítulo, como haría buildDocument({ markdown, continuation: start }, config), y encadena a partir de ahí los siguientes; {bookTotalPages} cuenta también las páginas anteriores al libro. Un manifiesto sin start se lee como antes, y un lector que no conoce el campo lo ignora. bookPageCount no forma parte de él: las páginas las cuenta quien lee. En un paquete con varios idiomas, localized[…].start da a una edición su propio comienzo.
const { bytes } = await createBundle({
name: 'Field notes, chapter 4',
markdown,
config,
// Página 58, par, en el capítulo 4.
start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start; // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel; // '58'#Ejemplo en vivo: abrir un paquete
El pen carga un libro de ejemplo de dos capítulos (lantern.postext, con su propia tipografía, una figura SVG y una tabla) desde el repositorio. Registra las fuentes e imágenes del paquete, compone el libro con buildBundle y pinta todas las páginas. Make the PDF renderiza los mismos documentos con postext-pdf, incrustando las fuentes del paquete. Elige un .postext tuyo, por ejemplo uno exportado del Sandbox, para verlo igual.
import {
openBundle,
loadBundleFonts,
registerBundleImages,
buildBundle,
bundleResourceBytes,
bundleFontProvider,
renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
async function show(data) {
// Chapters, config (fonts wired to the bundle's own files), resources and
// every file, keyed by its path inside the bundle.
const bundle = await openBundle(data);
// Layout measures text with the fonts the browser has: register the
// bundle's faces, and load the Google Fonts it names but does not carry
// (the default running heads use Open Sans; see the pen's CSS).
await loadBundleFonts(bundle);
await document.fonts.load('600 16px "Open Sans"');
await registerBundleImages(bundle);
// One VDTDocument per chapter, each continuing the one before it.
const docs = buildBundle(bundle);
const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
document.getElementById('pages').replaceChildren(...pages);
status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
+ (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
current = { bundle, docs };
pdfButton.disabled = false;
document.getElementById('links').hidden = true;
}
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
const id = family.toLowerCase().replace(/\s+/g, '-');
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
pdfButton.addEventListener('click', async () => {
pdfButton.disabled = true;
status.textContent = 'Rendering the PDF…';
const { bundle, docs } = current;
const bytes = await renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
pdfButton.disabled = false;
});
document.getElementById('file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
status.textContent = `Opening ${file.name}…`;
await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());index.html
<p>
<label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
<button id="pdf" disabled>Make the PDF</button>
<span id="links" hidden>
<a id="open" target="_blank" rel="noopener">open it</a> ·
<a id="download" download="book.pdf">download it</a>
</span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#pages {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
#pages canvas {
display: block;
width: 240px;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.
#Crear un paquete
createBundle escribe un archivo .postext a partir de un documento: sus capítulos, su configuración, sus recursos y los datos a los que estos hacen referencia.
import { createBundle } from 'postext';
const { bytes, manifest, warnings } = await createBundle({
name: 'El farol',
locale: 'es',
chapters: [
{ markdown: '# Anochecer\n\nSe dibuja en :ref{id="farol"}.' },
{ title: 'Noche', markdown: '# Noche\n\n…' },
],
config,
resources: [{
id: 'farol', typeId: 'figure', kind: 'svg', caption: 'El farol.',
svg: { fileId: 'farol.svg', width: 240, height: 150 },
createdAt: 0, updatedAt: 0,
}],
files: { 'farol.svg': svgMarkup, 'garamond-regular': fontBytes },
});| Entrada | Significado |
|---|---|
name, id, description, locale | Los metadatos del manifiesto. Por defecto id es un slug de name. |
chapters o markdown | El libro, un { title?, markdown } por capítulo, o un documento único. |
config | La PostextConfig. Los valores iguales a los de por defecto no se escriben en el manifiesto. |
resources | Los recursos. Una imagen nombra sus datos con bitmap.fileId / svg.fileId (y svg.pdfFileId para un máster de impresión). |
files | Los datos por fileId (un objeto o un Map): las imágenes que citan los recursos y los archivos de fuente que citan las variantes de config.customFonts. Cada valor puede ser un Uint8Array, un ArrayBuffer, un Blob o un texto (el código de un SVG). |
thumbnail | { data, mime }: una portada (PNG, JPEG, WebP, GIF o SVG). |
canvasScope | 'book' pide a los visores que compongan el libro entero como un solo lienzo. |
start | Dónde empieza el libro, para un paquete que contiene parte de uno más largo: la continuation con la que se compondría un documento suelto. Se escribe como start del manifiesto. |
mtime | La fecha de modificación que se escribe en cada archivo del zip (un Date, una marca de tiempo o una fecha en texto). Si falta, es la hora de la llamada, así que dos llamadas con los mismos datos dan bytes distintos. Con una fecha fija, los mismos datos dan siempre los mismos bytes, que se pueden comparar o resumir con un hash. Un zip guarda una fecha y una hora sin zona horaria, en pasos de dos segundos, de 1980 a 2099, y la fecha se escribe en la hora local de la máquina. Para que los bytes coincidan en cualquier máquina, construye la fecha con campos locales, como new Date(1980, 0, 1): una marca de tiempo o un texto que acaba en Z nombran un instante, que cae en una hora local distinta en cada zona horaria ('1980-01-01T00:00:00Z' todavía es 1979 al oeste de UTC). Una fecha fuera de esos años, en hora local, lanza un error. |
localized | Otros idiomas del mismo libro, por etiqueta de idioma: { en: { chapters?, config?, resources? } }. Las entradas anteriores pasan a ser el contenido de locale, que entonces es obligatorio. Consulta Paquetes bilingües. |
Devuelve los bytes del archivo, el manifest escrito como preset.json, todos los archivos en files (ruta → bytes) y una lista de warnings. Los archivos se nombran por el id de su recurso (resources/farol.svg) o por el nombre del archivo de fuente (fonts/…), y los capítulos por su orden y título (chapters/01-anochecer.md). Las fuentes se declaran en el campo fonts del manifiesto, nunca dentro de config.customFonts. Algunas cosas se dejan fuera, cada una con un aviso:
- un recurso o una variante tipográfica cuyos datos no están en
files - una variante
.woff(el backend PDF no puede incrustarla) - una familia marcada
redistributable: false
En el navegador, entrega bytes a un enlace de descarga: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). En Node, escríbelos con fs.writeFile. createBundle y openBundle no necesitan DOM. Los dists usan rutas de módulo sin extensión, así que con Node a secas, sin bundler, necesitan un hook de resolución. El archivo docs/examples/open-bundle/build-sample.mjs del repositorio muestra uno en pocas líneas.
#Paquetes bilingües
Un archivo .postext puede llevar un libro en varios idiomas, y openBundle(bytes, { locale }) lo lee en cualquiera de ellos. createBundle escribe uno a partir de localized: una entrada por cada idioma adicional, con lo que difiere del contenido principal (el de locale):
const { bytes, manifest } = await createBundle({
name: 'El farol',
locale: 'es',
chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
config,
resources: [figuraFarol, tablaHoras],
files: { 'farol.svg': svgEs, 'lantern.svg': svgEn },
localized: {
en: {
chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
config: { headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}' }] } },
resources: [
{ id: 'farol', caption: 'The lantern.', svg: { fileId: 'lantern.svg', width: 240, height: 150 } },
{ id: 'horas', caption: 'Hours of light.' },
],
},
},
});
const en = await openBundle(bytes, { locale: 'en' }); // capítulos, configuración y pies en ingléschapters: el libro en ese idioma. Los archivos de capítulo van a una carpeta por idioma (chapters/es/01-anochecer.md,chapters/en/01-dusk.md) y el campochaptersdel manifiesto pasa a ser un mapa idioma → capítulos. Un idioma sinchapterslee los principales; cuando ningún idioma tiene capítulos propios, siguen siendo una sola lista.config: la configuración de ese idioma. Cada clave de primer nivel sustituye por completo a la compartida cuando el paquete se lee en ese idioma, así queheadingssustituye aquí el objetoheadingsentero. Las claves que faltan, o que son iguales a las compartidas, se comparten y no se escriben, de modo que pasar la configuración completa del idioma funciona igual que pasar las pocas claves que cambian. Una clave con sus valores por defecto cuando la compartida no los tiene (layout: {}) se escribe tal cual, así que restablece el valor compartido. Las fuentes se comparten: las familias delcustomFontsde un idioma se suman alfontsdel paquete.resources: el texto de los recursos compartidos, emparejados porid:caption,note,altTexty eltablede una tabla. Una imagen con palabras puede tener su propio dibujo:bitmap.fileIdosvg.fileId(ysvg.pdfFileId) nombran otros datos defiles, que se escriben comoresources/en/farol.svg. Los demás campos, como el tipo o la colocación, se comparten. Un id que no está entre losresourcesse deja fuera con un aviso, y si falta la imagen de un idioma, ese idioma se queda con la compartida, también con un aviso.
El manifiesto enumera todos los idiomas en locales (['es', 'en']), conserva el principal como locale y guarda el resto en localized. openBundle sin idioma lee el principal.
Qué idioma recibe el lector. openBundle(bytes, { locale }) sirve el idioma exacto, si no su idioma base (es-MX lee es), y si no el principal; bundle.locale dice cuál sirvió. Capítulos y textos vienen siempre del mismo idioma. El idioma principal conserva los textos compartidos aunque localized traiga una variante regional suya: un paquete pt-PT con una entrada pt-BR lee los pies brasileños solo para pt-BR, y los compartidos para pt-PT y pt.
#Ejemplo en vivo: crear un paquete
El pen compone un libro de dos capítulos con una figura SVG y lista los archivos que escribió createBundle junto con el manifiesto. Ofrece el archivo para descargar, lo vuelve a abrir con openBundle y pinta su primera página: el viaje de ida y vuelta completo en pocas líneas. Importa el archivo descargado en el Sandbox para seguir trabajando en él allí.
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
<rect width="240" height="150" fill="#f3efe6"/>
<path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
<rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
<circle cx="120" cy="79" r="11" fill="#fff4c2"/>
<path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
const resources = [{
id: 'lantern',
typeId: 'figure',
kind: 'svg',
caption: 'The lantern by the door.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0,
updatedAt: 0,
}];
const text = '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.';
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
{ markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
{ markdown: `# Night\n\n${text}\n\n${text}` },
];
const config = {
layout: { layoutType: 'double' },
// Two short chapters that run on, with no blank verso between them (an
// H1 otherwise opens on a fresh recto), as in the open-bundle sample.
headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters,
config,
resources,
files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
const li = document.createElement('li');
li.textContent = `${path} (${data.length} B)`;
return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
// Round trip: open the file just written, the way any program would.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
`${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
<a id="download" download="lantern.postext">Download lantern.postext</a> ·
<a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
<section>
<h3>Files in the bundle</h3>
<ul id="files"></ul>
<h3>preset.json</h3>
<pre id="manifest"></pre>
</section>
<section>
<h3>Opened again: page 1</h3>
<div id="page"></div>
</section>
</div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#output {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
#output section {
flex: 1 1 280px;
min-width: 0;
}
h3 {
margin: 8px 0;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
pre {
max-height: 320px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 12px;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Carga un editor interactivo desde codepen.io. El ejemplo importa la última versión publicada de postext desde un CDN.
#Trabajar con paquetes
Como el Sandbox, el skill para agentes y el paquete postext leen y escriben el mismo archivo, un .postext es una forma cómoda de pasar un libro de una herramienta a otra:
- Partir de un paquete. Porta una publicación existente con el skill para agentes, o diseña un libro en el Sandbox y expórtalo (Descargar (.postext) en el menú ⋯ de su fila del panel Libros). Carga el archivo desde tu programa con
openBundlepara renderizarlo en canvas, HTML o PDF. Conserva el archivo como fuente del libro: edita en código los capítulos, la configuración o los recursos y vuelve a escribirlo concreateBundle, o simplemente vuelve a cargarlo cada vez que cambie. - Depurar y ajustar en el Sandbox. Cuando algo de lo que produce tu programa necesita retoques (una figura que cae en la página equivocada, un estilo de encabezado, el equilibrio de columnas), exporta lo que compone tu programa con
createBundle. Importa ese archivo en el Sandbox (Libros → Nuevo → Abrir un archivo .postext…), corrige el texto, el diseño o las figuras con la vista previa en vivo, el panel Revisión y la vista PDF, y vuelve a exportarlo. Después tu programa carga el archivo corregido conopenBundle. O copia lo que cambió de vuelta a tu código: laconfigdel manifiesto solo guarda los valores distintos de los de por defecto, así que se lee como una diferencia corta.
#API de bajo nivel
postext/bundle exporta también las piezas sobre las que se construyen openBundle y createBundle, para aplicaciones que guardan o sirven paquetes a su manera (un directorio descomprimido por HTTP, registros en una base de datos):
openBundleZip(bytes)/zipBundle(files, { mtime }): la capa del zip. Al abrir tolera una carpeta raíz e ignora las entradas__MACOSXy los archivos ocultos. Se rechazan las rutas que se salen del paquete.mtimefecha los archivos como el dato del mismo nombre decreateBundle.readBundle(manifest, readFile, options)lee un manifiesto más una funciónreadFile(ruta)y devuelve capítulos, configuración, recursos, imágenes y fuentes.optionsfija el idioma, cómo se nombran los identificadores de archivo (ids), la configuración base (baseConfig, bajo la del manifiesto; por defecto la paleta y los tipos de recurso debundleBaseConfigen el idioma del paquete, que devuelveresolveBundleConfigLocale(manifest, locale), y quien pase su propiabaseConfigdebe traducirla a ese idioma; con un manifiesto anterior aconfigVersion: 4, sumathse fija con el del paquete; con uno anterior a 5, el hueco en línea de sulayout, con uno anterior a 6, el hueco en los recuadros de sulayout, el sitio bajo los dos puntos de subodyText, las marcas en línea de susheadingsy el tamaño de sus letras capitales, con uno anterior a 7, los cortes tras una raya y la composición del texto en bandera de subodyText, y con uno anterior a 8, la división bajo un encabezado de susheadingsy los cortes tras el guion de un compuesto y el espacio bajo los contenedores:::paragraphsde subodyText; ver Paquetes escritos por postext 1.4 o anterior) y cómo se miden los tamaños intrínsecos.readResolutionlee la resolución de cada mapa de bits en su archivo y la guarda enbitmap.fileResolution; por defecto es true cuando el paquete fijalayout.bitmapResolution: 'file'.planBundle(meta, content)/resolveBundleFiles(plan, sources): el lado de escritura, separado en un plan puro (nombres de archivo y manifiesto) y la resolución de los bytes mediante las funcionesreadBlob/readFont.isBundleManifest(value), las funciones que eligen idioma (pickChapterSpecs,pickLocaleOverrides,pickBundleView,resolveBundleLocale,resolveBundleConfigLocale),svgSize/bitmapSize/bitmapInfo(los píxeles de un mapa de bits y la resolución que indica su archivo) y los tipos del formato (BundleManifest,BundleResourceSpec,BundleFontFamilySpec, …).CONFIG_VERSION,migrateConfig(config, configVersion, { content }),pinLegacyHeadingBreaks(config),pinLegacyMathSize(config),pinLegacyInlineGap(config),pinLegacyBoxResourceGap(config),pinLegacyHeadingMarks(config),pinLegacyDropCapSize(config),pinLegacyColonListRoom(config),pinLegacyBoxChildCut(config),pinLegacyDashBreaks(config),pinLegacyRaggedBreaking(config),pinLegacyHeadingSplit(config),pinLegacyParagraphContainerSpacing(config),pinLegacyHyphenBreaks(config)yLEGACY_MATH_SIZE(0,5 ÷ 0,442): una configuración guardada, en los términos actuales (ver Paquetes escritos por postext 1.4 o anterior).readBundlela aplica; una aplicación que guarda configuraciones a su manera también puede hacerlo, una vez por copia guardada.
El Sandbox está construido sobre estas piezas. Añade sus propios identificadores de almacenamiento y los registros de páginas de layouts.json.