Saltar al contenido principal

Capítulo 3.19 · Parte II · El oficio

Uso programático: paquetes .postext

Abrir, componer, renderizar y crear archivos .postext desde el código, los paquetes bilingües, los archivos de versiones anteriores y la API de bajo nivel

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

En pocas palabras

Esta página es para quienes escriben código. Un archivo .postext guarda un libro entero en un solo archivo: su texto, sus ajustes, sus fuentes y sus imágenes. La página enseña a abrir uno de esos archivos, componer sus páginas y dibujarlas. Enseña a crear un archivo nuevo a partir de tu propio texto y a guardar dos idiomas de un libro en el mismo archivo. También explica cómo se leen los archivos creados con versiones anteriores de Postext.

#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
CampoQué contiene
manifestEl preset.json ya validado.
id, name, descriptionTomados del manifiesto.
locale, localesEl 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 #.
configLa 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.
resourcesLos recursos, con los pies del idioma elegido. Un tamaño que falte en el manifiesto se lee del archivo.
fontsUna entrada por variante: { family, weight, style, format, file, bytes }.
filesTodos los archivos del zip, por su ruta.
thumbnail, canvasScopeLa 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.
startDó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.
warningsProblemas 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: 14: 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 headings sin 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 (ver layout.inlineResourceGap en 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.inlineResourceGapInBoxes en 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 (ver headings.inlineMarks en Encabezados).
  • El tamaño de una letra capital (reglas 6): hasta la 1.4, el dropCap de un texto de diseño sin fontSize era tan alto como todas las cajas de línea que abarca, con la parte alta por encima de la primera línea (ver dropCap en Elementos de texto).
  • El sitio bajo una línea de dos puntos (reglas 6): hasta la 1.4, keepColonWithList daba 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 (ver bodyText.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 splitMinLines líneas (ver layout.boxChildSplitMinLines en 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 (ver bodyText.breakAfterDashes en 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 (ver bodyText.optimalRagged en 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.keepWithNextSplit en 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 (el marginTop de un encabezado) se sumaba debajo y la separación entre párrafos del texto no contaba (ver bodyText.paragraphContainerSpacing en 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 (ver bodyText.breakAfterHyphens en Texto de cuerpo).
  • Los poemas sin separador (reglas 9): hasta la 1.22, un poema :::verse cuyas líneas no llevaban || se componía en hemistiquios sueltos, cada línea centrada (véase bodyText.verse.layout en Verso).
  • Una sangría de primera línea junto a una francesa (reglas 9): hasta la 1.22, la hangingIndent de un estilo de párrafo sustituía a su firstLineIndent, y la primera línea empezaba en indent (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 (ver bodyText.hardLineBreaks en 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 (ver codeStyle.blocks en 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.tighten en 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": 14; 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. Las reglas de 12 hacen que una página en una retícula de caracteres contenga sus líneas (#717): un manifiesto marcado 11 o anterior cuya configuración combinada activa cjk.grid.enabled recibe bodyText.avoidWidows: true, y en la región japan bodyText.avoidOrphans: true (pinLegacyGridBreakRules), en el bodyText vigente, cada una solo si la configuración no la fija ya. También mantienen dos caracteres de una nota en dos filas a cada lado de un corte de línea (#720): un manifiesto así recibe cjk.warichu.minChars: 1 (pinLegacyNoteBreaks) en el cjk vigente cuando un capítulo leído tiene una nota :warichu[ o la configuración combinada fija citations.notes: 'warichu', solo si la configuración no lo fija ya. Y en la región de Japón una palabra occidental lleva el espacio entre chino y latín junto a sus paréntesis y signos (#721): un manifiesto marcado 11 o anterior cuya configuración combinada se resuelve a la región de Japón recibe cjk.latinSpacingEdge: 'letters' (pinLegacyLatinSpacingEdge) en el cjk vigente cuando un capítulo leído tiene kana o kanji, solo si la configuración no lo fija ya. Y un recurso puesto en el texto admite placement.captionSide como un flotante de columna (#729): un manifiesto marcado 11 o anterior cuya configuración combinada fija layout.layoutType: 'oneAndHalf' recibe layout.inlineCaptionSide: false (pinLegacyInlineCaptionSide) cuando un capítulo leído incrusta un recurso, solo si la configuración no lo fija ya.

Las reglas de 12 cambian además cómo se equilibra una columna corta, y un manifiesto marcado 11 o anterior conserva aquello con lo que se compuso. El orden de las palancas (#707): recibe headings.balancing.maxLinesBeforeLoose con el valor de su propio maxLinesPerHeading, o 4 si no fija ninguno (pinLegacyLooseOrder), en el headings vigente, cuando un capítulo leído tiene un encabezado y la configuración combinada equilibra sus columnas, deja que un encabezado tome más de dos líneas y no fija ya la clave. La línea bajo una figura a varias columnas (#708): recibe headings.balancing.stretchAfterWideFloats: 'always' (pinLegacyWideFloatStretch) en el headings vigente, cuando un capítulo leído incrusta o cita un recurso o hace flotar una caja, y la configuración combinada equilibra sus columnas, no desactiva stretchAfterFloats y no fija ya la clave. Los cortes dentro de los grupos de un índice (#712): recibe index.groups.splitMinEntries: 1 (pinLegacyIndexGroupSplit) en el index vigente, cuando un capítulo leído tiene un cerco :::index en una línea propia y la configuración no fija ya la clave. La unidad del corte a nivel (#711): recibe headings.balancing.trailingStep: 'grid' (pinLegacyTrailingStep) en el headings vigente, cuando un capítulo leído puede poner texto fuera del interlineado base (un cerco :::, un cerco de código, una cita en bloque, una lista, una fórmula en bloque o una cita bibliográfica), y la configuración combinada equilibra sus columnas, mantiene activo trailing o beforeSpan y no fija ya la clave.

Las reglas de 12 cortan el rótulo de una referencia por sus espacios cuando el rótulo son palabras (#702). Un manifiesto marcado 11 o anterior recibe crossRefs.breakLabels: false (pinLegacyRefLabelBreaks) en el crossRefs vigente, cuando un capítulo leído contiene una referencia (:ref{…}, \ref{…}, \eqref{…} o un @id de pandoc-crossref) y la configuración no nombra el ajuste: cada rótulo sigue siendo la palabra única con la que se compuso.

Las reglas de 12 componen una palabra sin letras (46., 1367)) en la escritura del texto que la rodea (#725). Un manifiesto marcado 11 o anterior recibe bodyText.scriptContext: false (pinLegacyScriptContext) sobre el bodyText vigente, contengan lo que contengan sus capítulos, cuando la configuración no nombra el ajuste: esa palabra se compone aislada, como se maquetó. La regla actúa en una fuente que dibuja su puntuación según la escritura, y al leer un manifiesto no se sabe qué fuentes carga el libro.

Las reglas de 13 convierten en un ajuste el idioma en el que se compone el texto (#764), y las de 14 hacen del idioma del propio documento su valor por defecto (#770). Un manifiesto marcado 13 o anterior recibe bodyText.shapingLanguage: 'host' (pinLegacyShapingLanguage) sobre el bodyText vigente, contengan lo que contengan sus capítulos, cuando la configuración no nombra el ajuste; el que nombra un valor lo conserva. Su texto se mide en el idioma de la página que aloja la maquetación, y en ninguno dentro de un worker, como se maquetó. Un manifiesto marcado 13 lo escribió postext 1.29: la 1.29.1 y la 1.29.2 maquetaban en el idioma del anfitrión un libro que no nombra ningún valor, como hace la fijación; la 1.29.0, cuyo valor por defecto era 'document', lo maquetaba en el del documento con un canvas y un PDF que no seguían la medida, y también se lee con 'host'. Un manifiesto marcado 14 que no nombra ningún valor se maqueta en el idioma del documento.

Las reglas de 13 también dibujan la casilla de un elemento de tarea (- [ ], - [x]): un cuadrado que dibuja el motor, con una marca cuando la tarea está hecha, y que ocupa un espacio propio antes del texto del elemento (#765). Un manifiesto marcado 12 o anterior recibe unorderedLists.taskBox: 'character' (pinLegacyTaskBox) sobre el unorderedLists vigente, cuando algún capítulo contiene un elemento de tarea y la configuración no nombra el ajuste. Su casilla sigue siendo el carácter ☐ o ☑ compuesto en la fuente de la lista, y el texto del elemento empieza tras el ancho de ese carácter, como se maquetó. Ese ancho lo daba la fuente que tuviera el carácter, una del sistema en la mayoría de los libros, y al leer el manifiesto no se conoce.

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 en document.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 resolvedor resourceImageUrl de renderToHtml, y bundleVideoUrl(bundle) su resolvedor resourceVideoUrl para los archivos de vídeo que lleva un paquete. Las dos recolorean las figuras SVG cuando diagramStyle.singleInk está 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 }) y bundleImageUrl(bundle, { onWarning }) avisan de una familia sin variante.
  • buildBundle(bundle) compone los capítulos en orden y devuelve un VDTDocument por 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 que buildDocument, más config para sustituir la configuración del paquete, cache para compartir una caché de medidas y metadata (ver más abajo).
  • bundleResourceBytes(bundle) y bundleFontProvider(bundle, { decodeWoff2, fallback }) son las opciones resourceBytes y fontProvider de renderToPdf de postext-pdf. El proveedor de fuentes elige del paquete el peso más cercano del estilo pedido. Para una variante .woff2 necesita decompressWoff2, y para una familia que el paquete no incluye llama a fallback con los argumentos del renderizador, request incluido, y entrega lo que devuelva. Un fallback que solo descarga el archivo latin de la familia imprime como cajas vacías una familia china que el paquete no incrusta; uno que responde con los fragmentos, como sliceFontProvider en 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ítulo

Dó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.

Postext · abrir un paquete .postext
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 },
});
EntradaSignificado
name, id, description, localeLos metadatos del manifiesto. Por defecto id es un slug de name.
chapters o markdownEl libro, un { title?, markdown } por capítulo, o un documento único.
configLa PostextConfig. Los valores iguales a los de por defecto no se escriben en el manifiesto.
resourcesLos recursos. Una imagen nombra sus datos con bitmap.fileId / svg.fileId (y svg.pdfFileId para un máster de impresión).
filesLos 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.
startDó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.
mtimeLa 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.
localizedOtros 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és
  • chapters: 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 campo chapters del manifiesto pasa a ser un mapa idioma → capítulos. Un idioma sin chapters lee 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í que headings sustituye aquí el objeto headings entero. 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 del customFonts de un idioma se suman al fonts del paquete.
  • resources: el texto de los recursos compartidos, emparejados por id: caption, note, altText y el table de una tabla. Una imagen con palabras puede tener su propio dibujo: bitmap.fileId o svg.fileId (y svg.pdfFileId) nombran otros datos de files, que se escriben como resources/en/farol.svg. Los demás campos, como el tipo o la colocación, se comparten. Un id que no está entre los resources se 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í.

Postext · crear un paquete .postext
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 openBundle para 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 con createBundle, 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 con openBundle. O copia lo que cambió de vuelta a tu código: la config del 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 __MACOSX y los archivos ocultos. Se rechazan las rutas que se salen del paquete. mtime fecha los archivos como el dato del mismo nombre de createBundle.
  • readBundle(manifest, readFile, options) lee un manifiesto más una función readFile(ruta) y devuelve capítulos, configuración, recursos, imágenes y fuentes. options fija 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 de bundleBaseConfig en el idioma del paquete, que devuelve resolveBundleConfigLocale(manifest, locale), y quien pase su propia baseConfig debe traducirla a ese idioma; con un manifiesto anterior a configVersion: 4, su math se fija con el del paquete; con uno anterior a 5, el hueco en línea de su layout, con uno anterior a 6, el hueco en los recuadros de su layout, el sitio bajo los dos puntos de su bodyText, las marcas en línea de sus headings y 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 su bodyText, y con uno anterior a 8, la división bajo un encabezado de sus headings y los cortes tras el guion de un compuesto y el espacio bajo los contenedores :::paragraphs de su bodyText; ver Paquetes escritos por postext 1.4 o anterior) y cómo se miden los tamaños intrínsecos. readResolution lee la resolución de cada mapa de bits en su archivo y la guarda en bitmap.fileResolution; por defecto es true cuando el paquete fija layout.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 funciones readBlob / 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) y LEGACY_MATH_SIZE (0,5 ÷ 0,442): una configuración guardada, en los términos actuales (ver Paquetes escritos por postext 1.4 o anterior). readBundle la 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.