Saltar al contenido principal

Configuración de Postext

Actualizado: 2026-09-22|30 min|enes

Cada decisión de composición en Postext está controlada por un único objeto de configuración.

PostextConfig controla las dimensiones de página, la disposición de columnas, la tipografía del cuerpo de texto, los estilos de encabezado, el idioma del documento (locale) y más. Todas las propiedades son opcionales — Postext incluye valores por defecto sensatos, inspirados en la tipografía tradicional de libros. Solo necesitas especificar lo que quieras cambiar.

import { buildDocument } from 'postext';
 
const document = buildDocument(content, {
  page: { sizePreset: '21x28', dpi: 300 },
  layout: { layoutType: 'double', gutterWidth: { value: 0.5, unit: 'cm' } },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt sobrescribe el valor por defecto de 8 pt
  headings: { fontFamily: 'Open Sans' },
});

Para una visión general de cómo el motor procesa esta configuración, consulta la página de Arquitectura.

#Índice

Esta referencia es larga. Estos son los bloques principales:

#Página

La propiedad page controla las dimensiones físicas y la apariencia de la página.

PropiedadTipoPor defectoDescripción
sizePresetPageSizePreset'17x24'Tamaño de página predefinido. Establece 'custom' para usar ancho/alto explícitos.
widthDimension17 cmAncho de página. Se toma de sizePreset cuando se omite; un valor explícito siempre prevalece (usa sizePreset: 'custom' para tamaños totalmente personalizados).
heightDimension24 cmAlto de página. Se toma de sizePreset cuando se omite; un valor explícito siempre prevalece.
marginsPageMargins2 cm todos los ladosEspacio entre el borde de la página y el área de contenido. Cada lado (superior, inferior, izquierdo, derecho) se configura independientemente. Con mirror: true los márgenes son márgenes de páginas enfrentadas: left es el margen interior (del lomo) y right el exterior; las páginas impares (la página 1 es impar) los mantienen tal cual y las pares los intercambian, de modo que el área de contenido — y con ella las columnas, las bandas de flotantes, los contenedores de encabezado/pie y las bandas de apertura — se desplaza a lo largo del pliego. Por defecto false. Ver más abajo.
backgroundColorColorValuetransparentColor de fondo de la página.
dpinumber300Puntos por pulgada. Afecta a cómo se convierten las unidades físicas (cm, mm, in) a píxeles.
cutLinesCutLinesConfigdesactivadoMostrar marcas de corte en las esquinas de la página para impresión. Al activarlo, el lienzo se expande para incluir el área de sangrado y las marcas de corte. Ver más abajo.
baselineGridBaselineGridConfigdesactivadoSuperponer una rejilla base horizontal para la alineación del ritmo vertical. Ver más abajo.

#Márgenes simétricos (espejo)

Los libros se leen por pliegos, y el margen interior suele diferir del exterior. margins.mirror convierte los cuatro márgenes en márgenes de páginas enfrentadas:

{
  "page": {
    "margins": {
      "top": { "value": 2, "unit": "cm" },
      "bottom": { "value": 2.5, "unit": "cm" },
      "left": { "value": 2.2, "unit": "cm" },
      "right": { "value": 1.4, "unit": "cm" },
      "mirror": true
    }
  }
}

Con esta configuración toda página impar tiene 2,2 cm de margen a la izquierda (el lomo) y 1,4 cm a la derecha (el corte delantero); toda página par tiene 1,4 cm a la izquierda (el corte delantero) y 2,2 cm a la derecha (el lomo). Cada página compuesta lleva su propio contentArea en la VDTPage, así que todo lo que deriva de él — columnas, bandas de flotantes a todo el ancho, contenedores de encabezado y pie, y bandas de apertura con span: 'page' — sigue automáticamente la geometría espejada. Los marcos de página y sangre que usan los elementos de diseño anclados a 'page' / 'bleed' no se ven afectados: describen el pliego físico, no los márgenes.

#Tamaños de página predefinidos

PresetAnchoAltoUso habitual
'11x17'11 cm17 cmLibros de bolsillo
'12x19'12 cm19 cmFormato rústica estándar
'17x24'17 cm24 cmLibros técnicos, manuales
'21x28'21 cm28 cmRevistas, informes (cercano a A4)
Tamaños de página predefinidosCuatro tamaños de página predefinidos dibujados a escala proporcional: bolsillo 11x17, rústica 12x19, técnico 17x24 y cercano a A4 21x28 cm.21×28 · ~A417×24 · Técnico12×19 · Rústica11×17 · Bolsillo21 cm28 cm
Presets dibujados a escala proporcional.

#Rejilla base

La rejilla base dibuja líneas horizontales a intervalos que coinciden con la altura de línea del texto de cuerpo. Es una ayuda visual para asegurar el ritmo vertical — cuando está activada, el motor ajusta los bloques de encabezado a la rejilla para que el texto de cuerpo en columnas adyacentes se mantenga alineado. Las líneas cubren solo el texto real de la página — desde la primera línea de texto hasta la última — de modo que las bandas de flotantes, las páginas en blanco por paridad y el espacio sobrante al final no muestran rejilla.

PropiedadTipoPor defectoDescripción
enabledbooleanfalseSi se dibuja la rejilla base.
colorColorValue#ccccccColor de las líneas de la rejilla.
lineWidthDimension0.5 ptGrosor de las líneas de la rejilla.
page: {
  baselineGrid: { enabled: true, color: { hex: '#e0e0e0', model: 'hex' } }
}

#Marcas de corte

Al activarse, el lienzo se expande para incluir una zona de sangrado y el motor dibuja marcas de corte en cada esquina para producción impresa.

PropiedadTipoPor defectoDescripción
enabledbooleanfalseSi se expande el lienzo con sangrado y se dibujan las marcas de corte.
bleedDimension3 mmÁrea extra alrededor de la página usada como sangrado de impresión.
markLengthDimension5 mmLongitud de cada marca de corte.
markOffsetDimension3 mmSeparación entre la esquina de la página y el inicio de la marca de corte.
markWidthDimension0.25 ptGrosor de las marcas de corte.
colorColorValue#000000Color de las marcas de corte.

#Numeración

El bloque page.pageNumbering controla cómo se formatean las etiquetas de página y dónde empieza el contador. Define únicamente el valor por defecto de todo el documento — para reiniciar la numeración en medio del documento (por ejemplo, números romanos en las páginas preliminares que pasan a decimal desde 1 en los capítulos), usa la directiva :::numbering (consulta Formato del documento → Directivas).

PropiedadTipoPor defectoDescripción
format'decimal' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha''decimal'Estilo numérico utilizado para renderizar las etiquetas de página.
startAtnumber1Valor numérico asignado a la primera página, independientemente del formato. format: 'lower-roman', startAt: 1 produce i, ii, iii, …; format: 'decimal', startAt: 17 produce 17, 18, 19, ….

La etiqueta calculada se almacena en cada VDTPage como pageLabel y es el valor que resuelve el marcador {pageNumber} en encabezados y pies. Los PDFs emiten un árbol /PageLabels de modo que el indicador de página y la navegación «Ir a la página» de Preview / Acrobat coinciden exactamente con las etiquetas impresas.

#Disposición

La propiedad layout controla cómo se organizan las columnas dentro del área de contenido.

Columnas, medianil y margenUna página dividida en tres columnas: cada columna es el área de contenido para el texto, los medianiles son los huecos verticales entre columnas, y el margen es el borde en blanco entre los límites de la página y la primera columna.PáginaMargenColumnaMedianil
Las columnas contienen el texto. Los medianiles las separan. Los márgenes enmarcan el contenido.
Sistema de márgenesUna página con márgenes independientes superior, derecho, inferior e izquierdo alrededor del área de contenido.PáginaÁrea de contenidosup.1cminf.2cmizq.2.5cmder.1.5cm
Cada lado de la página puede tener su propio margen.
PropiedadTipoPor defectoDescripción
layoutType'single' | 'double' | 'oneAndHalf''double'Disposición de columnas. Ver más abajo para detalles de cada tipo.
gutterWidthDimension0.75 cmEspacio horizontal entre columnas. Solo aplica a disposiciones multicolumna.
sideColumnPercentnumber33Ancho de la columna lateral como porcentaje del área de contenido. Solo aplica a la disposición 'oneAndHalf'.
sideColumnRole'text' | 'floats''text'Qué lleva la columna lateral: texto corrido (fluye a ella tras la columna principal) o solo los recursos y avisos colocados con span: 'side' — una columna de margen solo para flotantes. Solo en 'oneAndHalf'.
sideColumnSide'right' | 'left' | 'outer' | 'inner''right'Borde del área de contenido en el que se sitúa la columna lateral. 'outer' / 'inner' siguen la paridad de la página cuando los márgenes están espejados (el borde exterior de una página impar es el derecho; el de una par, el izquierdo). Solo en 'oneAndHalf'.
columnRuleColumnRuleConfigdesactivadoFilete vertical opcional trazado entre columnas. Ver más abajo.
fitFiguresToPagebooleanfalseReduce una figura (mapa de bits o SVG) cuya imagen, pie y nota quedarían más altos que el área de contenido hasta que quepan en ella, y compone más pequeña una figura en línea que no cabe por poco en lo que queda de su columna (hasta la mitad de su ancho; el pie conserva la medida de la columna) para que siga junto a su texto. El visor HTML la activa, porque sus páginas son tan altas como la pantalla; las páginas impresas se dimensionan para sus figuras.

#Filete de columna

Dibuja una línea vertical fina en el medianil para separar visualmente las columnas.

PropiedadTipoPor defectoDescripción
enabledbooleanfalseSi se dibuja el filete de columna.
colorColorValue#ccccccColor del filete.
lineWidthDimension0.5 ptGrosor del filete.

#Tipos de disposición

  • 'single' — Una columna que ocupa todo el ancho del contenido. Ideal para páginas estrechas o contenido con párrafos largos.

  • 'double' — Dos columnas de igual ancho. La disposición editorial clásica — mantiene el ancho de carro en el rango óptimo de 40–50 caracteres para una lectura cómoda.

  • 'oneAndHalf' — Una disposición asimétrica con una columna principal y una columna lateral más estrecha. La columna lateral (controlada por sideColumnPercent) es ideal para notas al margen, figuras pequeñas o contenido complementario. Valores entre 25–40% funcionan bien. Con sideColumnRole: 'floats' el texto corrido nunca entra en la columna lateral: se convierte en un canal para las figuras, tablas y avisos colocados con span: 'side', apilados junto al párrafo que los cita por primera vez (un recurso que no cabe en lo que queda del canal espera al de la página siguiente), mientras que los flotantes y cajas span: 'page' siguen cruzando ambas columnas. Combinada con márgenes espejados y sideColumnSide: 'outer', el canal queda en el borde exterior de cada página — la columna marginal de un manual.

#Encabezados y pies

Las propiedades header y footer controlan los bloques de encabezado y pie de página. Los encabezados y pies se dibujan dentro de los márgenes de página existentes — no reservan espacio adicional ni reducen el área de contenido.

Marco del contenedor. Un elemento anclado a 'container' se coloca en la franja de margen entre el cuerpo y el borde de corte, con el ancho del área de contenido. El contenedor del encabezado va desde el borde superior de corte hasta la parte superior del cuerpo; el del pie, desde la parte inferior del cuerpo hasta el borde inferior de corte. Así, los anclajes top-* del encabezado y bottom-* del pie se miden desde el borde de corte, mientras que los bottom-* del encabezado y top-* del pie se miden desde el borde del cuerpo. El contenedor nunca incluye el sangrado ni la franja de las marcas de corte, de modo que un encabezado o pie queda en la misma posición en la página cortada tanto si page.cutLines está activado como si no. Ancla a 'page' (la caja de corte) o a 'bleed' para salir del ancho del área de contenido o llegar al sangrado.

Los bloques utilizan el modelo unificado de ranura de diseño: cada elemento define un placement con un anchor (al contenedor o a otro elemento por #id), un offset opcional y un size opcional. Los campos planos heredados align, marginFromBody, marginFromEdge y width: 'full' siguen aceptándose como entrada y se migran automáticamente al nuevo formato; debajo se documenta la equivalencia.

Cada bloque contiene una lista de elementos de texto, línea (rule) y caja (box). El orden del array es el orden de pintado (el primer elemento se pinta primero, el último se pinta encima). Esto se cumple sean cuales sean los anclajes: un elemento puede anclarse a otro que aparece después en la lista (anchor.to: '#ttl'), de modo que una caja de fondo puede ir primero y colocarse respecto al texto que se pinta encima de ella.

Valores por defecto incluidos. Cuando header o footer son undefined, postext aplica un valor por defecto sensato en lugar de un bloque vacío:

  • Encabezado por defecto: {title} alineado a la derecha en páginas impares, {chapterTitle} alineado a la izquierda en páginas pares y una línea a todo el ancho — todos en el color principal de la paleta, Open Sans 8pt/600, marginFromBody 16pt (texto) / 13pt (línea).
  • Pie por defecto: {pageNumber} centrado en todas las páginas en el color principal de la paleta, Open Sans 8pt/600, marginFromBody 16pt.

Para renunciar a los valores por defecto, establece header: { elements: [] } (o footer: { elements: [] }). Un array elements vacío explícito se conserva como «sin elementos» — solo undefined activa los valores por defecto.

PropiedadTipoPor defectoDescripción
elementsHeaderFooterElement[]valores por defecto integrados cuando es undefined; [] los desactivaLista ordenada de elementos de texto y línea.

#Elementos de texto

Los elementos de texto renderizan una plantilla con sustitución de marcadores. Los marcadores usan la sintaxis {nombre}; {{ y }} se emiten como llaves literales.

Los valores por defecto de la tabla siguiente son los de un elemento de texto que añades tú. El encabezado y el pie integrados descritos en Valores por defecto incluidos son elementos ya hechos con sus propios valores (Open Sans 8pt/600 en el color principal de la paleta), no los valores por defecto del elemento.

PropiedadTipoPor defectoDescripción
kind'text'Discriminador.
idstringId estable, único dentro del bloque. Otros elementos se anclan a él con anchor.to: '#id'. El sandbox asigna uno al crearlo.
contentstring''Plantilla. Admite los marcadores listados más abajo, además de — un atributo escrito en la línea del H1 del capítulo actual (# Título ). Un atributo ausente se resuelve como cadena vacía sin aviso.
align'left' | 'center' | 'right''center'Alineación horizontal dentro del bloque.
parity'all' | 'odd' | 'even''all'En qué páginas aparece el elemento (paridad por número de página: la página 1 es impar).
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'En qué roles de página aparece el elemento, combinado con parity. Tras la colocación cada página se clasifica como 'blank' (relleno de paridad o separador, o sin contenido), 'part' (página divisoria de parte), 'opener' (su primer bloque es un encabezado cuyo nivel abarca la página o fuerza un salto de página antes — la primera página de un capítulo) o 'body' (el resto). pages: 'body' oculta una cabecera corriente en las aperturas de capítulo; pages: 'opener' muestra un folio solo allí.
fontFamilystring'EB Garamond'Familia tipográfica.
fontSizeDimension8 ptTamaño de fuente.
fontWeightnumber400Grosor de fuente (100–900).
italicbooleanfalseSi se renderiza en cursiva.
colorColorValue#000000Color del texto.
overflow'wrap' | 'ellipsis-start' | 'ellipsis-middle' | 'ellipsis-end' | 'clip''wrap'Cómo se gestiona el texto que excede el ancho disponible del elemento. 'wrap' divide el texto en varias líneas; las variantes de elipsis truncan a una sola línea e insertan al principio, en el centro o al final; 'clip' recorta de forma estricta al rectángulo del elemento sin insertar ningún carácter.
verticalAlign'top' | 'middle' | 'bottom''middle'Dónde se sitúa el texto dentro de una caja más alta que sus líneas — un placement.size.height fijo, o una caja estirada por un vecino anclado.
lineHeightnumber1.2Interlineado de las líneas envueltas, como múltiplo de fontSize.
letterSpacingDimension0Tracking: espacio adicional que avanza tras cada carácter, espacios incluidos, exactamente como el letter-spacing de CSS. Las medidas crecen con él, así que una caja de ancho automático sigue ajustada.
textTransform'none' | 'uppercase''none'Transformación de mayúsculas aplicada al texto resuelto, marcadores incluidos — el título de una parte en versales en el índice.
boxElementBoxStyleFondo y borde opcionales dibujados tras el texto: backgroundColor, borderColor, borderWidth, borderRadius y un padding por lado que amplía la caja más allá del texto (ver Elementos de caja para los campos).
dropCap{ lines, fontFamily, fontWeight, fontSize, color, gap }Letra capital en un texto que se ajusta: la primera letra, grande, junto a las primeras lines líneas (2 por defecto), con su propia fuente, peso y color, y a gap del texto. fontSize vale por defecto el tamaño cuya altura de mayúscula abarca esas líneas.
paragraphIndentDimension0Sangría de primera línea de cada párrafo a partir del segundo. Un salto de línea en el contenido —o los dos caracteres \n, en un texto que viene de un atributo— separa párrafos; varios saltos seguidos cuentan como uno.
hyphenatebooleanfalseCuando es true y overflow es 'wrap', las palabras largas que aún excederían el ancho tras un salto de línea normal se dividen en límites silábicos (utilizando el idioma de separación silábica activo del documento) insertando un guion blando en el corte.
marginFromBodyDimension6 ptDistancia absoluta entre el borde del elemento que mira al cuerpo y el borde del cuerpo. Independiente de otros elementos. Migrado a placement.offset.y.
marginFromEdgeDimension0 ptDesplazamiento horizontal respecto al borde al que está alineado. Solo aplica cuando align es 'left' o 'right'. Migrado a placement.offset.x.
placementElementPlacementderivado de align + marginFromBody + marginFromEdgePosicionamiento avanzado (ver más abajo). Cuando se define, prevalece sobre los campos planos heredados.

Marcadores disponibles:

  • {pageNumber} — número de página actual (1-indexed).
  • {totalPages} — total de páginas del documento.
  • {title}, {subtitle}, {author}, {publishDate} — valores leídos desde content.metadata. Los metadatos desconocidos o vacíos se renderizan como cadena vacía (y generan un aviso en el sandbox).
  • {chapterTitle} — texto del H1 más reciente en o antes de la página actual.
  • {partTitle}, {partNumber} — título y número de la parte actual (la página :::part más reciente en o antes de la página actual; las páginas en blanco de paridad justo antes de una página de parte ya pertenecen a ella). Vacíos antes de la primera parte.

Alineación implícita por borde

Cuando un elemento de texto tiene placement.anchor.to apuntando a otro elemento mediante #id, el borde del anclaje implica una alineación por defecto para las líneas envueltas:

  • right-of y align-left implican align: 'left' — las líneas envueltas fluyen hacia la derecha desde el anclaje.
  • left-of y align-right implican align: 'right' — las líneas envueltas se pegan al lado más cercano al elemento de referencia.

El editor de encabezados del sandbox aplica estas alineaciones implícitas automáticamente al cambiar el borde o el destino del anclaje. Mantienen el texto envuelto visualmente atado al elemento al que se relaciona (de modo que, p. ej., la "P" de un "Postext" envuelto queda verticalmente bajo la "I" de "Introducción").

#Elementos de línea

Los elementos de línea dibujan una línea: horizontal a lo ancho del bloque, o vertical a lo alto.

PropiedadTipoPor defectoDescripción
kind'rule'Discriminador.
idstringId estable, único dentro del bloque, para referencias anchor.to: '#id'.
direction'horizontal' | 'vertical''horizontal'Una línea horizontal recorre placement.size.width ('fill' = hasta el borde del contenedor) y mide thickness de alto. Una vertical recorre placement.size.height ('fill' o sin definir = hasta el borde del contenedor) y mide thickness de ancho — un separador entre el titulillo y el folio.
colorColorValue#000000Color del trazo.
thicknessDimension0.5 ptGrosor de la línea.
widthDimension | 'full''full''full' abarca el área de contenido; una Dimension restringe la línea a un largo fijo posicionado por align.
align'left' | 'center' | 'right''center'Alineación cuando width no es 'full'.
marginFromBodyDimension6 ptDistancia absoluta entre el borde de la línea que mira al cuerpo y el borde del cuerpo. Independiente de otros elementos.
marginFromEdgeDimension0 ptDesplazamiento horizontal respecto al borde alineado. Solo aplica cuando width es una Dimension fija y align es 'left' o 'right'.
parity'all' | 'odd' | 'even''all'En qué páginas aparece la línea.
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'Roles de página en los que aparece la línea (ver el campo pages de los elementos de texto).
placementElementPlacementderivado de align + marginFromBody + marginFromEdgePosicionamiento avanzado (ver Posicionamiento de elementos). size.width / size.height fijan la longitud de la línea; width: 'fill' es el antiguo 'full'.

#Elementos de caja

Los elementos de caja pintan un rectángulo redondeado dentro del bloque — útiles como fondo tras un texto en aperturas de capítulo, columnas laterales o pies. Las cajas se posicionan exclusivamente mediante el campo placement; no tienen variante plana heredada. El relleno, el trazo y el radio de esquina viven en el objeto anidado style (ElementBoxStyle), como en el ejemplo JSON de «Posicionamiento de elementos».

PropiedadTipoPor defectoDescripción
kind'box'Discriminador.
idstringId estable, único dentro del bloque. Los elementos hermanos se anclan a él con anchor.to: '#id'. El sandbox asigna uno al crear el elemento.
style.backgroundColorColorValuetransparentColor de relleno. Pon transparent para una caja solo de borde.
style.borderColorColorValuetransparentColor del trazo.
style.borderWidthDimension0 ptGrosor del trazo. El trazo se pinta hacia el interior del rectángulo de la caja, así las dimensiones exteriores no varían.
style.borderRadiusDimension0 ptRadio de esquina. Se acota a la mitad del lado más corto en tiempo de renderizado.
placementElementPlacementObligatorio. Ver «Posicionamiento de elementos» más abajo.
parity'all' | 'odd' | 'even''all'En qué páginas aparece la caja.
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'Roles de página en los que aparece la caja (ver el campo pages de los elementos de texto).

#Elementos de imagen

Un elemento image dibuja un recurso de mapa de bits o SVG del documento — el logotipo de la editorial en la portada, una marca en una cabecera. Su tamaño lo fija placement.size: si uno de los lados width / height queda en 'auto' (el valor por defecto), el otro sigue la proporción de la imagen; con ambos fijados, la imagen se ajusta dentro de la caja, centrada. Un recurso inexistente o que no sea una imagen no dibuja nada.

{
  kind: 'image', id: 'logo', resourceId: 'logo-editorial',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: { value: 64, unit: 'mm' }, y: { value: 233, unit: 'mm' } }, size: { width: { value: 83, unit: 'mm' }, height: 'auto' } },
}
PropiedadTipoPor defectoDescripción
idstringIdentificador estable; otros elementos pueden anclarse a él como #id.
resourceIdstringId de un Resource de mapa de bits o SVG del documento.
placementElementPlacementAnclaje, desplazamiento y tamaño (ver Posicionamiento de elementos). Un lado 'fill' llega hasta el borde del contenedor.
parity, pagescomo arriba'all'En qué páginas aparece la imagen.

El backend PDF incrusta el recurso como una figura (un SVG con máster de impresión lo usa); el visor HTML lo resuelve mediante resourceImageUrl.

#Posicionamiento de elementos

ElementPlacement es el modelo unificado de posicionamiento que utilizan todos los tipos de elementos (texto, línea, caja) dentro de cualquier ranura de diseño — encabezado de página, pie de página o ranura de diseño avanzado a nivel de encabezado. Tres campos describen un placement:

interface ElementPlacement {
  /** A qué ancla este elemento y a qué borde de ese destino. */
  anchor: {
    to: 'container' | 'page' | 'bleed' | `#${string}`; // container = el bloque; page = caja de corte; bleed = caja de corte + sangre; #id = otro elemento
    edge: AnchorEdge;
  };
  /** Distancia desde el punto de anclaje. */
  offset?: { x?: Dimension; y?: Dimension };
  /** Ancho / alto opcional. El ancho admite también 'fill' (extender a lo largo del bloque).
   *  `maxWidth` limita un ancho 'auto' (texto): el elemento sigue ajustándose a su
   *  contenido, así que los elementos anclados a él no se despegan, pero un texto largo
   *  se corta o se pliega ahí — una cabecera puede reservar sitio para la etiqueta que
   *  cuelga de ella en vez de expulsarla. */
  size?: { width?: Dimension | 'fill' | 'auto'; height?: Dimension | 'fill' | 'auto'; maxWidth?: Dimension };
}

AnchorEdge admite:

  • Bordes del contenedor (cuando anchor.to es 'container', 'page' o 'bleed'): top, top-left, top-right, bottom, bottom-left, bottom-right, left, right.
  • Bordes relativos a otro elemento (cuando anchor.to === '#someId'): right-of, left-of, below, above, align-top, align-bottom, align-left, align-right.

anchor.to: 'page' ancla el elemento a la caja de corte (la página física una vez cortada) y 'bleed' a la caja de corte ampliada por cutLines.bleed en todos los lados (idéntica a la caja de corte mientras las marcas de corte estén desactivadas). Ambos marcos pasan a ser también la referencia de size: 'fill' y del recorte automático de anchura, de modo que una banda de color puede ir de borde a borde independientemente de los márgenes de página:

{ "kind": "box", "id": "band", "placement": { "anchor": { "to": "bleed", "edge": "top-left" }, "size": { "width": "fill", "height": { "value": 6, "unit": "cm" } } }, "style": { "backgroundColor": { "hex": "#1d3557", "model": "hex" } } }

Dentro de la ranura de diseño avanzado de un encabezado, los elementos anclados a la página o a la sangre no aumentan la altura reservada para el encabezado salvo que se extiendan por debajo de su borde superior (una banda en la parte superior de la página queda detrás de la apertura; una banda que baje más allá del encabezado empuja el cuerpo hacia abajo). Usa advancedDesign.minHeight para reservar una altura fija de apertura en cualquier caso.

Cada elemento tiene un id estable (que asigna automáticamente el sandbox; también puedes establecerlo a mano). Los elementos anclados a otros elementos forman un pequeño grafo de dependencias que el motor resuelve antes de medir, así un elemento puede encadenarse a otro sin coordenadas manuales.

La forma heredada align + marginFromBody + marginFromEdge se interpreta a la entrada y se reescribe a un placement en el momento de resolver la configuración, así que las configuraciones existentes siguen funcionando sin cambios.

#Texto de cuerpo

La propiedad bodyText controla la tipografía de todo el texto de párrafo.

Escala tipográficaJerarquía tipográfica desde H1 hasta texto pequeño, mostrando tamaños relativos de encabezados, texto de cuerpo y pies.H1Encabezado 132pxH2Encabezado 224pxH3Encabezado 320pxCuerpoTexto de cuerpo16pxPequeñoPie / nota13px
Una escala consistente mantiene la jerarquía legible de un vistazo.
Escala de espaciadoUna escala de espaciado por pasos con valores crecientes usados en márgenes, rellenos y huecos.xs4 px×1sm8 px×2md16 px×4lg24 px×6xl40 px×102xl64 px×164 px
Los pasos de espaciado construyen un ritmo predecible en la maquetación.
PropiedadTipoPor defectoDescripción
fontFamilystring'EB Garamond'Familia tipográfica para el cuerpo de texto. Cualquier fuente de Google Fonts, del sistema, o declarada como fuente personalizada.
fontSizeDimension8 ptTamaño de fuente base para el cuerpo de texto.
lineHeightDimension1.5 emEspaciado vertical entre líneas. Las unidades relativas (em, rem) escalan con el tamaño de fuente.
paragraphSpacingbooleanfalseCuando está activado, inserta una línea en blanco (igual al lineHeight) entre párrafos consecutivos, como hacen algunas editoriales.
colorColorValue#000000Color del texto.
boldColorColorValueColor principal (#295AA3)Color aplicado a los fragmentos en negrita. Se resuelve contra la entrada main-color de la paleta por defecto, de modo que cambiar ese color retintea todos los fragmentos en negrita del documento.
italicColorColorValueColor principal (#295AA3)Color aplicado a los fragmentos en cursiva. Mismo enlace a la paleta que boldColor.
referenceColorColorValueColor principal (#295AA3)Color aplicado a las etiquetas de :ref en línea (referencias a recursos). Mismo enlace a la paleta que boldColor.
referenceBoldbooleantrueRenderizar las etiquetas de :ref en línea con la fuente en negrita.
referenceItalicbooleanfalseRenderizar las etiquetas de :ref en línea en cursiva.
textAlign'left' | 'justify''justify'Alineación del texto. El texto justificado distribuye el espaciado a lo largo de cada línea para bordes uniformes. Las últimas líneas de los párrafos justificados se componen en bandera, a su ancho natural — salvo cuando Knuth-Plass aceptó una línea final sobrellenada confiando en la compresión del pegamento (glue): en ese caso los espacios entre palabras se comprimen para que la línea encaje exactamente en la medida (semántica de glue-setting de TeX, aplicada de forma idéntica en los backends canvas, HTML y PDF).
fontWeightnumber400Peso para el texto normal (100–900).
boldFontWeightnumber700Peso para texto en negrita/strong (100–900).
hyphenationHyphenationConfigactivada, 'en-us'Configuración de separación silábica automática. Ver más abajo.
firstLineIndentDimension1.5emSangría aplicada a la primera línea de cada párrafo (o a todas las líneas excepto la primera cuando la sangría francesa está activada).
hangingIndentbooleanfalseCuando está activado, la sangría se aplica a todas las líneas excepto la primera (sangría francesa).
indentAfterHeadingbooleantrueCuando vale false, el primer párrafo inmediatamente posterior a un encabezado se compone sin sangría de primera línea — convención tipográfica habitual en publicaciones científicas y en muchos estilos editoriales. No tiene efecto cuando hangingIndent está activado.
maxWordSpacingnumber2Límite superior del espaciado entre palabras en texto justificado, expresado como multiplicador del ancho del espacio normal. Las líneas que superan esta proporción se consideran "flojas".
minWordSpacingnumber0.6Límite inferior del espaciado entre palabras en texto justificado, como multiplicador del ancho del espacio normal.
optimalLineBreakingbooleantrueUsar ruptura óptima de líneas Knuth-Plass en lugar de voraz de primer ajuste. Produce un espaciado entre palabras más uniforme en todo el párrafo. Ver Separación silábica y justificación.

#Separación silábica

Cuando la alineación del texto está configurada como 'justify', la separación silábica evita el espaciado excesivo entre palabras al romper las palabras largas en los límites silábicos. El motor utiliza patrones TeX/Liang para encontrar puntos de ruptura naturales en los límites silábicos. Ver Separación silábica y justificación para una explicación detallada.

PropiedadTipoPor defectoDescripción
enabledbooleantrueSi se permite la separación silábica.
localeHyphenationLocale'en-us'Reglas de idioma para los límites silábicos.

Idiomas soportados: 'en-us' (inglés), 'es' (español), 'fr' (francés), 'de' (alemán), 'it' (italiano), 'pt' (portugués), 'ca' (catalán), 'nl' (neerlandés).

Las palabras de menos de 5 caracteres nunca se separan. El motor requiere al menos 2 caracteres antes y 3 caracteres después de un punto de ruptura.

Idioma del documento

El locale de primer nivel es el idioma del documento en su conjunto. Admite los mismos valores que hyphenation.locale y es el valor al que recurre ese campo cuando no está definido, de modo que a un libro en español le basta locale: 'es' para separar sílabas en español. También elige el idioma de las cadenas integradas de continuación de tablas ((cont.) / Continued frente a Continúa, ver Tablas más altas que la página) y es el idioma que se etiqueta en un PDF accesible. Sin definir, el motor asume 'en-us'; el sandbox recurre al idioma de la interfaz y expone el campo al principio de la sección Texto de cuerpo.

const config: PostextConfig = {
  locale: 'es',
  bodyText: { textAlign: 'justify', hyphenation: { enabled: true } }, // separa sílabas en español
};

#Huérfanas, viudas, runts y reglas de cohesión

Consulta Separación silábica y justificación para entender la mecánica de deméritos que hay detrás. Esta sección es la referencia de las claves de bodyText que gobiernan esas penalizaciones.

Más allá de la separación silábica y los límites de espaciado, la configuración del cuerpo expone las reglas blandas que evitan las rupturas de párrafo estructuralmente incómodas. Todas ellas se inyectan como deméritos en el algoritmo de ruptura de líneas Knuth-Plass — sesgan la maquetación hacia rupturas limpias sin imponer nunca una regla dura. Pon a 0 cualquier *Penalty para desactivar esa penalización.

PropiedadTipoPor defectoDescripción
avoidOrphansbooleantrueDesaconsejar que un párrafo termine con menos de orphanMinLines líneas al principio de la siguiente columna.
orphanMinLinesnumber2Líneas mínimas requeridas al principio de la siguiente columna cuando un párrafo se parte. Solo activo cuando avoidOrphans es true.
orphanPenaltynumber1000Demérito añadido cuando se incumple la restricción de huérfanas. Valores más altos sesgan más fuertemente al algoritmo; 0 desactiva la penalización.
avoidOrphansInListsbooleantrueCuando es true, los elementos de lista también reciben protección contra huérfanas (no solo los párrafos). Solo efectivo si avoidOrphans es true.
avoidWidowsbooleantrueDesaconsejar que un párrafo empiece con menos de widowMinLines líneas al final de la columna actual.
widowMinLinesnumber2Líneas mínimas requeridas al final de la columna actual cuando un párrafo se parte. Solo activo cuando avoidWidows es true.
widowPenaltynumber1000Demérito añadido cuando se incumple la restricción de viudas. 0 desactiva la penalización.
avoidWidowsInListsbooleantrueCuando es true, los elementos de lista también reciben protección contra viudas. Solo efectivo si avoidWidows es true.
avoidRuntsbooleantrueDesaconsejar que los párrafos terminen con una última línea muy corta — un runt, p. ej. una única palabra corta aislada.
runtMinCharactersnumber20Umbral aproximado de caracteres para la última línea de un párrafo. Internamente se interpreta como runtMinCharacters × anchoDeEspacioNormal píxeles: la prueba real es "¿es la última línea visualmente más corta que N espacios de contenido?".
runtPenaltynumber1000Penalización equivalente a badness (se inyecta en la fórmula cuadrática de Knuth–Plass, en la misma escala que el badness de línea, que satura en 10000). 0 desactiva la penalización.
avoidRuntsInListsbooleantrueCuando es true, los elementos de lista también reciben la penalización de runt. Solo efectivo si avoidRunts es true.
tightenRuntsbooleantrueCuando la penalización no ha podido evitar una línea huérfana de cierre, compone el párrafo con una línea menos: los espacios se aprietan (nunca por debajo de minWordSpacing) y, si con eso no basta, entra además algo de tracking negativo. Requiere optimalLineBreaking y avoidRunts.
maxRuntTrackingnumber10Tracking máximo que puede tomar ese ajuste, en milésimas de eme (la unidad de InDesign: 10 = 0,01 em por carácter), aplicado como apriete. Con 0 el ajuste se limita al espaciado entre palabras.
slackWeightnumber10Peso aplicado al coste cuadrático del "espacio de columna no usado". Valores más altos hacen que la maquetación prefiera llenar las columnas al máximo; 0 desactiva esta presión por completo.
keepColonWithListbooleantrueCuando un párrafo termina con dos puntos que introducen directamente una lista, mantiene la línea de los dos puntos unida a la lista: si colocar el párrafo no deja sitio para el primer elemento de la lista en la misma columna/página, la última línea (o el párrafo entero, si tiene una sola línea) se mueve a la siguiente columna junto con la lista. Cuando esta regla tenga que empujar el párrafo completo y justo antes haya una secuencia de títulos en la columna, esos títulos también se arrastran hacia adelante para que headings.keepWithNext siga cumpliéndose.

Sobre los runts. Un runt es un párrafo cuya última línea es demasiado corta para sentirse como una línea de texto propiamente dicha — típicamente una o dos palabras cortas varadas al final del párrafo. Como la comprobación se basa en el ancho en píxeles de la línea relativo al ancho del espacio normal, runtMinCharacters se adapta automáticamente al tamaño de fuente actual. Una palabra corta visualmente más ancha que runtMinCharacters × anchoDeEspacio es válida; una palabra más estrecha que eso (o verdaderamente sola) dispara la penalización. Para quien sienta curiosidad matemática: con el runtPenalty por defecto de 1000, evitar un runt domina sobre cualquier alternativa que exigiera un estiramiento del espacio entre palabras de hasta aproximadamente r≈2,15.

Blandas, no duras. Ninguna de estas reglas puede impedir una ruptura — el motor siempre produce una maquetación. Son deméritos: el algoritmo combina en una única optimización global la "badness" (desigualdad), el coste de la separación silábica, la suavidad de clase de encaje y estas penalizaciones estructurales, y elige el conjunto de rupturas con el coste total más bajo. Si necesitas una garantía más dura, sube la penalización; si un documento concreto se lee mejor con la penalización relajada, bájala.

#Encabezados

La propiedad headings controla la tipografía de todos los niveles de encabezado (H1–H6). Puedes establecer valores generales por defecto que aplican a todos los niveles, y después sobrescribir propiedades específicas por nivel.

#Valores generales por defecto

PropiedadTipoPor defectoDescripción
fontFamilystring'Open Sans'Familia tipográfica para todos los encabezados.
lineHeightDimension1.2 emAltura de línea para encabezados. Más ajustada que el cuerpo de texto.
colorColorValueColor principal (#295AA3)Color del texto de encabezado. Enlazado a la entrada main-color de la paleta por defecto, de modo que cambiar ese color retintea todos los encabezados.
textAlign'left' | 'justify''left'Alineación del texto de encabezado.
fontWeightnumber700Peso de fuente para encabezados (100–900).
marginTopDimension1.5 emEspacio sobre los encabezados.
marginBottomDimension0.5 emEspacio bajo los encabezados.
keepWithNextbooleantrueCuando es true, un encabezado nunca se coloca como último elemento de una columna o página. Si el siguiente bloque no tuviera al menos bodyText.widowMinLines líneas de hueco tras el encabezado (o una línea cuando bodyText.avoidWidows es false), el encabezado se empuja hacia delante para quedar unido a su texto. Interactúa con bodyText.keepColonWithList: si esa regla tiene que empujar el párrafo de dos puntos por completo, los encabezados que lo preceden en la columna viajan con él en vez de quedar varados.
snapToGridbooleantrueSi el flujo vuelve a la retícula base bajo un título. Con true el marginBottom del título se redondea a líneas enteras de la retícula; con false se conserva el margen exacto y el texto bajo el título puede quedar fuera de la retícula hasta el siguiente punto de ajuste (el final de una lista, la cola de un :::paragraphs, una ecuación) — como muchos libros, que dejan línea y media bajo el título.
balancingColumnBalancingConfigactivadoEquilibrado vertical de columnas — espacio adicional sobre los encabezados para que las columnas terminen alineadas con el pie de página. Ver más abajo.

#Equilibrado de columnas

Las editoriales esperan que cada columna empiece en la parte superior de la página y termine alineada con su parte inferior. Las reglas de ruptura (protección de huérfanas y viudas, títulos unidos a su texto, figuras indivisibles) dejan columnas cortas de forma natural — una o más líneas de rejilla base vacías al final. Con el equilibrado activado, el motor hace lo que haría un maquetista, aplicando tres palancas en orden de prioridad editorial:

  1. Una caja que cierra la columna — un callout que termina una columna corta baja exactamente el espacio que queda bajo su pie, de modo que su borde inferior cae en la última casilla de rejilla de la página, a nivel con la última línea de la columna contigua.
  2. Encabezados — se añaden líneas completas de rejilla al margen superior de los encabezados de la columna corta. Cuando hacen falta varias líneas y la columna contiene varios encabezados, las líneas se reparten entre ellos, dejando siempre la mayor parte al encabezado más importante (un h2 recibe más que un h3). Los encabezados situados al principio de una columna nunca reciben espacio adicional, de modo que las columnas siguen empezando en la parte superior.
  3. Finales de lista — cuando los encabezados no pueden absorber todo el hueco, se añade una línea de rejilla donde termina una lista o enumeración (el aire tras una lista se lee con naturalidad), con un máximo por lista.
  4. Párrafos corridos — como último recurso, se recompone un párrafo de la columna con una línea más (el \looseness=+1 de TeX), eligiendo el párrafo más largo para que el espaciado adicional se diluya de forma invisible. La solución corrida solo se acepta cuando todas sus líneas quedan por debajo de bodyText.maxWordSpacing — el color tipográfico nunca supera el límite que ya tengas configurado. Requiere bodyText.optimalLineBreaking.

La última columna de una página solo se equilibra cuando la página fluye de forma natural hacia la siguiente — la página final de un capítulo termina corta de forma legítima.

PropiedadTipoPor defectoDescripción
enabledbooleantrueSi se equilibran los finales de columna.
maxLinesPerHeadingnumber4Número máximo de líneas de rejilla adicionales que pueden añadirse sobre un mismo encabezado.
stretchAfterListsbooleantruePermite líneas de rejilla adicionales donde termina una lista, cuando los encabezados no pueden absorber todo el hueco.
maxLinesAfterListnumber1Número máximo de líneas de rejilla adicionales tras el final de una misma lista.
stretchAfterFloatsbooleantruePermite líneas de rejilla adicionales bajo una figura o tabla que encabeza la columna corta (un flotante superior), tras la palanca de los finales de lista, de modo que el texto de debajo baja en lugar de acabar la columna corta.
maxLinesAfterFloatnumber1Número máximo de líneas de rejilla adicionales bajo un mismo flotante superior.
looseParagraphsbooleantrueÚltimo recurso: recompone párrafos de una columna corta con una línea más (una línea extra cada uno), sin superar bodyText.maxWordSpacing.
maxLooseParagraphsnumber2Cuántos párrafos de una misma columna corta pueden ganar una línea, los más largos primero.
trackParagraphsbooleantrueCuando el espaciado entre palabras no basta para ganar la línea, el párrafo corrido puede tomar además el menor tracking positivo (interletrado) que lo consiga.
maxTrackingnumber10Límite de ese tracking, en milésimas de eme por carácter (10 = 0,01 em).
trailingbooleantrueNivela la banda de cierre de cada capítulo y del documento: cuando el flujo termina antes de llenar la página (en una apertura de capítulo, un :::part, una caja placement: 'fixed' que cierra el capítulo o el final del documento) con las columnas desiguales, se cortan a la misma altura — un tope de banda de ceil(Σ usado / N / rejilla) líneas, resuelto después de que las palancas anteriores hayan asentado las páginas previas —, de modo que una bibliografía corta termina a la misma altura en todas las columnas en lugar de llenar la primera y dejar la última a medias. Solo actúa cuando enabled es true.
beforeSpanbooleantrueNivela la banda que deja atrás un bloque a ancho de página: cuando un aviso span: 'page' no cabe bajo las columnas actuales ni siquiera tras un corte nivelado, y tiene que pasar a la página siguiente o partirse (calloutStyles[].keepTogether: false), las columnas que interrumpe se cortan a la misma altura — el mismo tope de cierre que recibe una banda final — en lugar de que la primera llene la página y la última termine corta. La página queda como salto explícito para que las palancas anteriores no vuelvan a estirar su última columna hasta el pie; la caja, o la parte que cabe, queda entonces bajo las columnas niveladas. Solo actúa cuando enabled es true.

#Configuración por nivel

Cada nivel de encabezado puede sobrescribir los valores generales a través del array levels. Solo fontSize (más el breakBefore de H1, indicado abajo) difiere por defecto — todas las demás propiedades se heredan de la configuración general de encabezados.

NivelTamaño de fuente por defectobreakBefore por defecto
H118 pt{ enabled: true, parity: 'always-odd' }
H215 pt{ enabled: false, parity: 'any' }
H312 pt{ enabled: false, parity: 'any' }
H410 pt{ enabled: false, parity: 'any' }
H59 pt{ enabled: false, parity: 'any' }
H68 pt{ enabled: false, parity: 'any' }

El valor por defecto de H1 reproduce la maqueta clásica de un libro: cada encabezado de nivel superior se abre en una página derecha (impar) nueva, con una página separadora en blanco obligatoria que cierra el capítulo anterior. Anula levels[0].breakBefore si tu documento es más plano que un libro.

Las configuraciones por nivel soportan las mismas propiedades que los valores generales — fontSize, lineHeight, fontFamily, color, fontWeight, marginTop, marginBottom — más dos campos exclusivos de cada nivel:

PropiedadTipoPor defectoDescripción
italicbooleanfalseRenderiza el encabezado en cursiva. Se combina con el fontWeight.
textTransform'none' | 'uppercase''none'Pasa a mayúsculas el título del encabezado (el prefijo de numeración se conserva tal cual). Conserva la longitud del texto para que el mapa de origen del editor siga siendo 1:1: los caracteres cuya mayúscula se expande (ßSS) se dejan como están. El título transformado alimenta también el marcador {titleText} de los diseños avanzados y los abridores de capítulo.
numberingTemplatestring''Plantilla del número automático del nivel. Un token imprime el contador acumulado de ese nivel de encabezado, opcionalmente formateado con un sufijo — romanos en mayúscula, romanos en minúscula, / alfabético, con cero a la izquierda — y el resto del texto es literal ('Capítulo . ', '.'; una barra invertida escapa una llave literal). Un token cuyo contador aún está vacío se pliega junto con el separador contiguo. Vacía (el valor por defecto) significa sin número automático. El número resultante se antepone al título en el flujo, alimenta el marcador de una ranura de diseño avanzado (donde el prefijo no se antepone) y se imprime en el índice de contenidos.
breakBeforeHeadingBreakBeforeConfigH1: { enabled: true, parity: 'always-odd' }
H2–H6: { enabled: false, parity: 'any' }
Fuerza un salto de página antes de cada encabezado de este nivel. parity: 'odd' / 'even' restringe además en qué lado del pliego se abrirá — se inserta una página en blanco de relleno cuando sea necesario (sigue contando para la numeración). 'always-odd' / 'always-even' garantizan además al menos una página separadora en blanco obligatoria entre el contenido anterior y el nuevo encabezado (la separadora pertenece al capítulo anterior; cualquier relleno de paridad adicional pertenece al nuevo). Cuando el encabezado es el primer bloque del documento y la primera página sigue vacía, la imposición de paridad se omite — el encabezado aterriza en la página 1 tal cual.
headings: {
  fontFamily: 'Merriweather',
  levels: [
    // Preajuste canónico de libro: los capítulos se abren en la página derecha (impar).
    { level: 1, fontSize: { value: 24, unit: 'pt' }, breakBefore: { enabled: true, parity: 'odd' } },
    { level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true },
  ]
}

#Saltar antes

breakBefore es ortogonal a los controles de numeración: activarlo fuerza un salto de página, pero el contador numérico solo se reinicia cuando insertas explícitamente una directiva :::numbering. Las páginas de relleno por paridad cuentan como páginas reales en la secuencia y reciben encabezados/pies según sus reglas normales de impar/par.

Valores de paridad

ValorComportamiento
'any' (por defecto)Sin restricción de paridad. El encabezado simplemente se abre en la siguiente página.
'odd'Garantiza que el encabezado se abra en una página impar (lado derecho). Solo se inserta una página en blanco si la siguiente página natural sería par.
'even'Igual pero para una página par (lado izquierdo).
'always-odd'Garantiza al menos una página separadora en blanco obligatoria entre el contenido anterior y el nuevo encabezado, y después fuerza la paridad impar. Útil cuando cada capítulo debe empezar un pliego nuevo.
'always-even'Igual pero para una página par.

Pertenencia de las páginas en blanco

Las páginas en blanco que inserta breakBefore reciben el encabezado {chapterTitle} en función del motivo de su inserción:

  • Las páginas insertadas para satisfacer una restricción de paridad ('odd', 'even', o la cola de paridad de 'always-*') pertenecen al capítulo entrante. Su marcador {chapterTitle} resuelve al título del nuevo capítulo — porque la página en blanco solo existe para empujar al nuevo capítulo hasta la paridad correcta.
  • La página separadora obligatoria que inserta 'always-odd' / 'always-even' pertenece al capítulo anterior. Es una pausa de cierre de capítulo deliberada, así que {chapterTitle} sigue mostrando el título del capítulo saliente.

Excepción al inicio del documento

Cuando el primer bloque del documento es un encabezado con breakBefore activado — o la fuente empieza con :::pagebreak —, la imposición de paridad se omite mientras la primera página siga vacía. El encabezado aterriza en la página 1 tal cual, independientemente de la paridad configurada, de modo que un documento que comienza con # Capítulo 1 con parity: 'odd' no arrastra una página en blanco inicial innecesaria. Una vez que se ha colocado cualquier contenido, la imposición de paridad funciona de forma habitual.

#Span y diseño avanzado

Cada nivel de encabezado admite dos campos adicionales que controlan cómo se renderiza el encabezado como apertura de capítulo a página completa.

PropiedadTipoPredeterminadoDescripción
span'column' | 'page''column'Cuando es 'page', el encabezado se trata como apertura de capítulo y su diseño avanzado (si está habilitado) se adjunta a la página como una banda de apertura por encima del cuerpo. Combínalo con breakBefore.enabled: true para que la apertura comience fiablemente en una nueva página.
advancedDesignHeadingAdvancedDesignConfigRanura de diseño de composición libre para este nivel. Cuando está enabled, los elementos de la ranura componen la apertura. Utiliza dentro de un elemento de texto para renderizar el texto del título; , , etc. para insertar el número del encabezado.
advancedDesign.minHeightDimensionAltura mínima reservada para el encabezado en el flujo de la columna. La altura reservada es max(fondo del contenido del diseño, minHeight), así que una apertura puede empujar el cuerpo hacia abajo (o reclamar la página entera) aunque sus elementos sean bajos o estén anclados a los marcos de página/sangre por encima del encabezado. Se aplica siempre que enabled sea true, incluso con la ranura vacía.

Ejemplo — una apertura de capítulo minimalista que muestra «Capítulo N» encima del título:

{
  "headings": {
    "levels": [
      {
        "level": 1,
        "span": "page",
        "breakBefore": { "enabled": true, "parity": "always-odd" },
        "advancedDesign": {
          "enabled": true,
          "slot": {
            "elements": [
              {
                "kind": "text",
                "id": "chapterLabel",
                "placement": {
                  "anchor": { "to": "container", "edge": "top" },
                  "offset": { "y": { "value": 48, "unit": "pt" } },
                  "size": { "width": "fill" }
                },
                "content": "Capítulo {numberRoman}",
                "fontSize": { "value": 10, "unit": "pt" },
                "align": "center",
                "overflow": "ellipsis-end"
              },
              {
                "kind": "text",
                "id": "chapterTitle",
                "placement": {
                  "anchor": { "to": "#chapterLabel", "edge": "below" },
                  "offset": { "y": { "value": 12, "unit": "pt" } },
                  "size": { "width": "fill" }
                },
                "content": "{titleText}",
                "fontSize": { "value": 24, "unit": "pt" },
                "fontWeight": 700,
                "align": "center",
                "overflow": "wrap",
                "hyphenate": true
              }
            ]
          }
        }
      }
    ]
  }
}

Marcadores disponibles dentro de una ranura de diseño de encabezado:

  • {titleText} — texto plano del encabezado (sin prefijo numérico).
  • {number} — número formateado según numberingTemplate.
  • {numberDecimal}, {numberRoman}, {numberRomanLower}, {numberAlpha}, {numberAlphaLower} — formatos alternativos.
  • {chapterNumber}, {chapterTitle}, {pageNumber}, {totalPages}, {title}, {subtitle}, {author}, {publishDate} — metadatos compartidos.
  • {attr.<clave>} — un atributo escrito en la propia línea del encabezado (# Título {author="I. Zango Martín"}), con el atributo del H1 del capítulo actual como respaldo. Los atributos ausentes se resuelven como cadena vacía sin aviso.

#Listas no ordenadas

La propiedad unorderedLists controla cómo se renderizan las listas con viñetas (-, *, +) y las listas de tareas estilo GFM (- [ ], - [x]). Se admiten hasta cinco niveles de anidamiento.

#Valores por defecto de listas no ordenadas

PropiedadTipoPor defectoDescripción
fontFamilystringhereda bodyText.fontFamilyFuente del texto de los elementos.
colorColorValueColor principal (#295AA3)Color del texto y de las viñetas de los elementos. Enlazado a la entrada main-color de la paleta por defecto.
fontWeightnumber700Peso del texto de los elementos (100–900). Las viñetas heredan este peso salvo que se sobrescriba por nivel.
italicbooleanfalseRenderiza el texto de los elementos en cursiva.
bulletCharstring'•'Glifo utilizado como viñeta.
bulletFontSizeDimension1 emTamaño del glifo de la viñeta. Las unidades relativas escalan con el tamaño del cuerpo de texto.
gapDimension0.5 emEspacio horizontal entre la viñeta y el texto del elemento.
indentDimension0 emSangría base para el nivel 1. Los niveles más profundos se encadenan desde el inicio del texto del nivel anterior salvo que se especifique (ver más abajo).
bulletVerticalOffsetDimension0 emAjuste fino vertical de la viñeta. Valores negativos la suben; positivos la bajan.
marginTop / marginBottomDimension1.5 emEspacio antes y después del bloque de lista.
itemSpacingDimension0 emEspacio vertical extra entre elementos, añadido sobre la altura de línea.
hangingIndentbooleantrueCuando está activo, las líneas envueltas se alinean con el primer carácter de texto en lugar de hacerlo bajo la viñeta (sangría francesa).
levelsUnorderedListLevelConfig[]Sobrescrituras por profundidad para los niveles 1–5. Ver más abajo.

#Extensiones para listas de tareas

Los elementos de tarea GFM (- [ ] …, - [x] …) se renderizan como elementos de lista no ordenada reemplazando la viñeta por una casilla. Los siguientes campos solo aplican a las tareas:

PropiedadTipoPor defectoDescripción
taskCheckboxCharstring'☐'Glifo para tareas no marcadas.
taskCheckedCharstring'☑'Glifo para tareas completadas.
taskCompletedStrikethroughbooleantrueDibuja una línea tachando el texto de las tareas completadas.
taskCompletedColorColorValuehereda el color del elementoColor opcional aplicado al texto de las tareas completadas. Cuando se omite, se usa el color habitual del elemento.

#Sobrescrituras por nivel (listas no ordenadas)

Cada entrada de levels apunta a una profundidad (1–5) y puede sobrescribir cualquiera de las siguientes propiedades:

PropiedadTipoDescripción
bulletCharstringGlifo de viñeta para esta profundidad.
fontFamilystringFuente del texto en esta profundidad.
fontSizeDimensionTamaño del glifo de viñeta en esta profundidad.
colorColorValueColor del texto del elemento.
fontWeightnumberPeso del texto del elemento.
italicbooleanActiva la cursiva.
indentDimensionSangría explícita de la viñeta en esta profundidad. Ver la regla de encadenamiento a continuación.
verticalOffsetDimensionAjuste fino vertical de la viñeta en esta profundidad.

Encadenamiento de sangrías. El nivel 1 arranca siempre con el indent general (por defecto 0 em — las viñetas quedan pegadas al borde de la columna). Para los niveles 2–5, si dejas indent sin definir el motor coloca la viñeta en el inicio de texto del nivel anterior (sangría del padre + ancho de viñeta + gap). Define un indent explícito en un nivel para romper el encadenamiento y fijar esa profundidad donde prefieras.

unorderedLists: {
  bulletChar: '—',
  gap: { value: 0.4, unit: 'em' },
  hangingIndent: true,
  levels: [
    { level: 2, bulletChar: '·' },
    { level: 3, bulletChar: '◦', color: { hex: '#666666', model: 'hex' } },
  ],
}

#Listas ordenadas

La propiedad orderedLists controla las listas numeradas (1., 2), etc.). Se admiten hasta cinco niveles de anidamiento y cada profundidad puede usar un formato de numeración distinto.

#Valores por defecto de listas ordenadas

PropiedadTipoPor defectoDescripción
fontFamilystringhereda bodyText.fontFamilyFuente del texto y del marcador numérico.
colorColorValueColor principal (#295AA3)Color del texto y del marcador numérico. Enlazado a la entrada main-color de la paleta por defecto.
fontWeightnumber700Peso del texto y del marcador numérico (100–900).
italicbooleanfalseRenderiza el texto en cursiva.
numberFormatOrderedListNumberFormat'arabic'Estilo de numeración: 'arabic', 'lower-alpha', 'upper-alpha', 'lower-roman', 'upper-roman'.
separatorstring'.'Carácter situado entre el número y el texto — normalmente '.' o ')'.
separatorFontFamilystringhereda fontFamilyFuente del separador. Cuando algún estilo del separador difiere del número, el separador se dibuja como una tirada propia tras el número (alineado a la derecha) — p. ej. 1 en Optima Bold negro seguido de en DIN Pro Bold azul.
separatorFontWeightnumberhereda fontWeightPeso del separador (100–900).
separatorItalicbooleanhereda italicRenderiza el separador en cursiva.
separatorColorColorValuehereda colorColor del separador. Se respetan las referencias a la paleta.
separatorGapDimension0 emEspacio entre el número y el separador. El texto del elemento sigue empezando gap después del separador.
numberFontSizeDimension1 emTamaño del marcador numérico.
gapDimension0.5 emEspacio horizontal entre el número y el texto del elemento.
indentDimension0 emSangría base para el nivel 1; los niveles más profundos se encadenan desde el inicio del texto del nivel anterior salvo que se especifique.
numberVerticalOffsetDimension0 emAjuste fino vertical del marcador numérico.
marginTop / marginBottomDimension1.5 emEspacio antes y después del bloque de lista.
itemSpacingDimension0 emEspacio vertical extra entre elementos.
hangingIndentbooleantrueLas líneas envueltas se alinean con el primer carácter de texto, no bajo el número.
levelsOrderedListLevelConfig[]Sobrescrituras por profundidad para los niveles 1–5.

#Sobrescrituras por nivel (listas ordenadas)

Cada entrada de levels puede sobrescribir numberFormat, separator, fontFamily, fontSize, color, fontWeight, italic, indent, verticalOffset y el estilo del separador (separatorFontFamily, separatorFontWeight, separatorItalic, separatorColor, separatorGap) — se aplica el mismo encadenamiento de sangrías que en las listas no ordenadas. El estilo del separador de un nivel hereda el estilo de su propio número salvo que se indique el ajuste general del separador.

Alineación a la derecha. El pipeline mide el número formateado más ancho dentro de cada recorrido e indenta todos los elementos de ese recorrido para que los marcadores queden alineados por su borde derecho. Una lista de diez elementos renderizada como 1.10. desplaza los números de un dígito a la derecha para que el separador caiga siempre en la misma columna.

orderedLists: {
  numberFormat: 'arabic',
  separator: '.',
  levels: [
    { level: 2, numberFormat: 'lower-alpha' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' },
  ],
}

Esto produce la clásica combinación anidada:

1. Primer elemento
   a. Subelemento
      i) Nota profunda
   b. Subelemento
2. Segundo elemento

#Matemáticas

La propiedad math controla cómo se analizan y se renderizan las fórmulas LaTeX escritas entre delimitadores $...$ (en línea) y $$...$$ (en bloque). El motor subyacente es MathJax (el paquete mathjax-full) en modo de salida SVG, rasterizado en el canvas e incrustado como glifos escalables en el PDF.

interface MathConfig {
  enabled?: boolean;        // Renderizar LaTeX. Si es false, los spans salen como TeX literal.
  fontSizeScale?: number;   // Multiplicador aplicado al tamaño del texto de cuerpo.
  color?: ColorValue;       // Color de la fórmula; hereda del cuerpo si se omite.
  marginTop?: Dimension;    // Espacio encima de las fórmulas en bloque.
  marginBottom?: Dimension; // Espacio mínimo debajo; el snap a rejilla puede aumentarlo.
}
PropiedadTipoPor defectoDescripción
enabledbooleantrueCuando es false, los spans $...$ y $$...$$ siguen analizándose (los avisos por delimitadores sin cerrar siguen disparándose), pero se renderizan como su código TeX literal. Útil cuando el contenido contiene signos de dólar a propósito o cuando quieres desactivar por completo el renderizado matemático.
fontSizeScalenumber1.0Multiplicador aplicado a bodyText.fontSize antes del renderizado. 1.0 iguala al texto de cuerpo; valores en el rango 0.9–1.1 son habituales cuando la fuente matemática parece ligeramente más grande o más pequeña que la fuente de prosa.
colorColorValuehereda del cuerpoColor de la fórmula renderizada. Omítelo para heredar bodyText.color. Fíjalo explícitamente cuando quieras tintar las fórmulas de forma distinta a la prosa — por ejemplo para que coincidan con el acento de los encabezados.
marginTopDimension0.8emEspacio por encima de una fórmula en bloque. Se ignora para fórmulas en línea.
marginBottomDimension0.8emEspacio por debajo de una fórmula en bloque. Con la rejilla base activada este valor se trata como mínimo — el snap puede ampliarlo para que la siguiente línea base caiga en una línea de la rejilla.
math: {
  enabled: true,
  fontSizeScale: 1.0,
  color: { hex: '#295AA3', model: 'hex' },
  marginTop: { value: 1, unit: 'em' },
  marginBottom: { value: 1, unit: 'em' },
}

El resolver y el stripper siguen el mismo patrón que las otras secciones:

import {
  DEFAULT_MATH_CONFIG,
  resolveMathConfig,
  stripMathDefaults,
} from 'postext';
 
const resolved = resolveMathConfig(config.math);
const minimal  = stripMathDefaults(config.math);

Para la gramática del documento ($...$, $$...$$, cómo escapar un dólar literal), consulta Formato del documento.

#Tipos de recurso

Un tipo de recurso es una categoría definible por el usuario — Figura, Tabla, Diagrama, Listado… — que determina cómo se numeran, cómo se rotula su pie y cómo se referencian los recursos de esa clase. La lista vive en config.resourceTypes; el sandbox la edita mediante una sección específica del panel de configuración.

Cuando config.resourceTypes no está definido, Postext incluye dos valores por defecto integrados: Figura y Tabla, ambos numerados como {h1}.{n} (reiniciando en cada encabezado de nivel 1) con contadores decimales.

Los valores por defecto integrados se adaptan al idioma. La función exportada defaultResourceTypes(locale = 'en') localiza los nombres de tipo, las etiquetas cortas y los prefijos de pie al idioma del documento — el inglés produce Figure/Fig. y Table/Tab.; el español produce Figura/Fig. y Tabla/Tabla. Las etiquetas regionales como es-ES se resuelven por idioma, y cualquier idioma sin traducción cae al inglés. El comportamiento de numeración (numberingTemplate: '{h1}.{n}', resetOn: 'h1', contadores decimales) es independiente del idioma. Cada llamada devuelve objetos nuevos, así que puedes mutar el resultado libremente:

import { defaultResourceTypes } from 'postext';
 
const types = defaultResourceTypes('es');
// => [{ id: 'figure', name: 'Figura', shortLabel: 'Fig.', captionPrefix: 'Figura',
//       numberingTemplate: '{h1}.{n}', resetOn: 'h1', counterFormat: 'decimal', … },
//     { id: 'table',  name: 'Tabla',  shortLabel: 'Tabla', captionPrefix: 'Tabla', … }]
type ResourceCounterFormat =
  | 'decimal'
  | 'roman-lower'
  | 'roman-upper'
  | 'alpha-lower'
  | 'alpha-upper';
 
type ResourceCounterReset = 'never' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
 
interface ResourcePlacement {
  position?: 'auto' | 'top' | 'bottom' | 'here'; // qué hueco libre puede ocupar el flotante; 'here' = incrustado en línea en la directiva ::resource
  span?: 'column' | 'page' | 'side';             // una columna, todo el ancho de contenido o la columna lateral solo para flotantes
  rotate?: 'ccw' | 'cw';                         // un cuarto de vuelta: una tabla apaisada en una página propia
  width?: number;                                // fracción (0 < width < 1) del ancho de la columna o de la página; por defecto, todo el ancho
  align?: 'left' | 'center' | 'right';           // dónde se sitúa un flotante más estrecho que su columna; por defecto 'left'
  captionSide?: boolean;                         // pie junto a la figura, en la columna lateral de una disposición oneAndHalf (solo flotantes de columna)
}
 
interface ResourceType {
  id: string;                          // id estable, referenciado por Resource.typeId
  name: string;                        // nombre singular, p. ej. "Figura"
  namePlural?: string;                 // plural opcional, p. ej. "Figuras"
  shortLabel: string;                  // etiqueta corta para refs en línea, p. ej. "Fig."
  numberingTemplate: string;           // "{h1}.{n}" o "{n}"
  resetOn: ResourceCounterReset;       // cuándo se reinicia el contador {n}
  counterFormat: ResourceCounterFormat;// cómo se formatea {n}
  captionPrefix: string;               // prefijo del pie, p. ej. "Figura"
  defaultPlacement?: ResourcePlacement;// colocación de respaldo para los recursos de este tipo
}

ResourcePlacement tiene la misma forma que el placement que un recurso define por su cuenta. position elige la clase de hueco libre que puede ocupar el flotante — auto (el valor por defecto) toma el primero tras la primera referencia, top / bottom lo limitan a esa clase de banda, here incrusta el recurso en línea. span fija la extensión del flotante: una columna, todo el ancho de contenido o la columna lateral solo para flotantes de una disposición de columna y media. rotate gira el recurso un cuarto de vuelta y lo convierte en un flotante a ancho de página en una página propia. width estrecha el flotante a una fracción de su columna (o de la página, en un flotante a ancho de página) — una tabla pequeña en una columna ancha, por ejemplo. align indica dónde se sitúa ese flotante más estrecho — a la izquierda por defecto, centrado o a la derecha. captionSide pone el pie junto a la figura en la columna lateral solo para flotantes de una disposición de columna y media (layout.sideColumnRole: 'floats'), a la altura del borde superior de la figura (del inferior en un flotante de pie); solo se aplica a los flotantes de columna, y una página sin esa columna mantiene el pie bajo la figura. Cuando ni el recurso ni su tipo definen una colocación, el valor integrado es auto / column.

PropiedadTipoDescripción
idstringIdentificador estable referenciado por el typeId de cada recurso. Se fija al crear el tipo; borrar un tipo al que aún apuntan recursos genera un aviso de tipo colgante.
namestringNombre singular. Lo usa la referencia en línea con style="full" (p. ej. Figura 1.7).
namePluralstring (opcional)Nombre plural, para etiquetas de la interfaz y listas de recursos.
shortLabelstringAbreviatura compacta que usa el estilo de referencia en línea por defecto (p. ej. Fig. 1.7).
numberingTemplatestringPlantilla del número calculado. Ver Tokens de plantilla más abajo. Las formas habituales son (ámbito de capítulo, p. ej. 2.3) y (un único conteo continuo).
resetOnResourceCounterReset'never' da un conteo continuo para todo el documento; 'h1'..'h6' reinician el contador cada vez que se encuentra un encabezado de ese nivel (o de cualquier ancestro). Ajústalo para que coincida con el nivel de encabezado que aparece en la plantilla — p. ej. con resetOn: 'h1'.
counterFormatResourceCounterFormatCómo se renderiza el contador : decimal (1, 2, 3), romano minúsculo/mayúsculo (i, ii / I, II) o alfabético minúsculo/mayúsculo (a, b / A, B). Los tokens de encabezado (…) se renderizan siempre como decimales.
captionPrefixstringTexto que se antepone al pie de figura/tabla. El número calculado sigue al prefijo — un pie se renderiza como . , p. ej. Figura 1.7. El plano original.
defaultPlacementResourcePlacement (opcional)Colocación usada por los recursos de este tipo que no definen su propio placement: position, span, rotate, width, align y captionSide, cada uno resuelto por separado. Cuando ni el recurso ni el tipo definen un campo, se aplica el valor integrado: auto / column, sin girar, todo el ancho, alineado a la izquierda, pie bajo la figura. Ver Numeración y referencias más abajo para la cadena de resolución y Formato del documento › Recursos para lo que hace cada valor, incluidos los recursos girados.
captionStyleCaptionStyleConfig (opcional)Sobrescritura parcial del estilo de pies para los recursos de este tipo. Solo las claves que indiques sustituyen al captionStyle global; el resto se hereda (un color sobrescrito arrastra también el color de la etiqueta y de la nota salvo que se fijen explícitamente). Uso típico: tablas con el pie encima sobre una barra de color mientras las figuras conservan su pie debajo. Las referencias a la paleta se resuelven como cualquier otro color.

#Tokens de plantilla

numberingTemplate se renderiza con el mismo motor que la numeración de encabezados (ver Encabezados). Reconoce dos clases de token:

  • {n} — el contador por tipo, formateado según counterFormat. Es el valor que se incrementa por recurso y se reinicia según resetOn.
  • {h1}{h6} — los números de encabezado vigentes en el punto de la primera referencia, renderizados siempre como decimales. {h1} es el número del encabezado de nivel 1 actual, {h2} el de nivel 2, y así sucesivamente.

Cualquier otro texto es literal. Una barra invertida escapa un {, } o \ literal. Cuando un token de encabezado no tiene valor en su ámbito (p. ej. {h1} antes de cualquier encabezado de nivel 1), colapsa junto con su separador adyacente — así {h1}.{n} degrada con elegancia al contador a secas.

PlantillaCon h1 = 2, contador = 3Notas
3Un único conteo continuo. Combínalo con resetOn: 'never'.
2.3Ámbito de capítulo. Combínalo con resetOn: 'h1'.
2.0.3Ámbito de sección. Combínalo con resetOn: 'h2'.

#Numeración y referencias

El número que produce un tipo de recurso es lo que imprime :ref y lo que sigue al prefijo del pie. :ref{id} es la forma principal: la primera referencia en orden de lectura incorpora el recurso, que flota al primer hueco libre tras esa referencia — el final de la columna de la referencia, el principio o el final de la siguiente columna vacía, o una banda de la página siguiente (según su colocación resuelta — position: 'auto' | 'top' | 'bottom' | 'here' y span: 'column' | 'page' | 'side', más rotate, width, align y captionSide, resuelta por recurso, luego por el defaultPlacement de su tipo, y por último el valor integrado auto / column; 'top' / 'bottom' limitan la búsqueda a huecos de ese tipo). La inserción en bloque ::resource{id} es opcional y solo se necesita para placement.position: 'here' — una inserción en línea, sin flotado, en un punto exacto del flujo. La gramática completa del lado del documento — ambas formas más las opciones style y text de :ref — se documenta en Formato del documento › Recursos, incluido cómo el orden de primera referencia determina el conteo.

Esto refleja la numeración de encabezados: igual que un nivel de encabezado lleva un numberingTemplate, un tipo de recurso también lo lleva — pero el contador del recurso ({n}) avanza por primera referencia en lugar de por encabezado, y resetOn lo ata de vuelta a la jerarquía de encabezados.

#Estilo de tablas

La propiedad tableStyle controla la tipografía y la decoración de las tablas-recurso —de todas, salvo las que eligen un estilo de tabla con nombre—. Las celdas de cuerpo y de cabecera se estilizan de forma independiente. La familia tipográfica, el tamaño y los colores heredan del texto de cuerpo resuelto cuando no se indican, de modo que un documento sin tableStyle renderiza las tablas con la tipografía del cuerpo.

const config: PostextConfig = {
  tableStyle: {
    headerBold: true,
    headerBackground: { hex: '#f0f0f0', model: 'hex' },
    borders: true,
    borderWidth: { value: 0.75, unit: 'pt' },
  },
};
PropiedadTipoPor defectoDescripción
bodyFontFamilystringfuente del cuerpoFamilia tipográfica de las celdas de cuerpo.
bodyFontSizeDimensiontamaño del cuerpoTamaño de fuente de las celdas de cuerpo.
bodyColorColorValuecolor del cuerpoColor del texto de las celdas de cuerpo.
headerFontFamilystringfuente del cuerpoFamilia tipográfica de las celdas de cabecera.
headerFontSizeDimensiontamaño del cuerpoTamaño de fuente de las celdas de cabecera.
headerColorColorValuecolor del cuerpoColor del texto de las celdas de cabecera.
headerBoldbooleantrueRenderizar las celdas de cabecera en negrita.
headerItalicbooleanfalseRenderizar las celdas de cabecera en cursiva.
headerBackgroundEnabledbooleantruePintar un relleno tras la fila de cabecera.
headerBackgroundColorValue#f0f0f0Color de relleno de la fila de cabecera.
bodyBackgroundEnabledbooleanfalsePintar un relleno tras las filas de cuerpo.
bodyBackgroundColorValue#ffffffColor de relleno de las filas de cuerpo (solo se pinta cuando está activado).
bordersbooleantrueDibujar los bordes de las celdas.
borderColorColorValuecolor del cuerpoColor del trazo de los bordes.
borderWidthDimension0.75ptGrosor del trazo de los bordes (≈1px a 96 DPI; escala con los DPI de la página).
cellPaddingDimension0.375emRelleno interior de cada celda.
rules'grid' | 'horizontal' | 'outer' | 'none''grid'Qué filetes trazar cuando borders está activo: la rejilla completa de celdas, solo filetes horizontales (borde superior e inferior de cada fila, sin verticales), solo el marco exterior, o ninguno.
borderRadiusDimension0Radio de las esquinas del marco exterior de la tabla. El marco se traza redondeado (con los filetes grid u outer), los fondos de las celdas y de la cabecera se recortan a él —también con rules: 'none' o sin bordes— y los filetes horizontales se recortan a su contorno exterior; los filetes interiores siguen rectos. Una tabla partida entre páginas redondea las esquinas superiores de su primera parte y las inferiores de la última. Se limita a la mitad del ancho y del alto de la tabla.
overflow'split' | 'clip' | 'hide''split'Qué ocurre con una tabla más alta que la página: continuarla en las páginas siguientes, conservar solo las filas que caben, o no colocarla. Véase más abajo.
continuedSuffixstring'(cont.)'Se añade, en cursiva, al pie de cada parte continuada de una tabla dividida.
continuesMarkerEnabledbooleantrueColoca un indicador bajo cada parte que continúa en la página siguiente.
continuesMarkerstring'Continúa' / 'Continued'Texto de ese indicador, alineado a la derecha bajo la parte con la tipografía de la nota (véase estilo de pies). El valor por defecto sigue el idioma del documento.

Los grosores de borde se conservan fraccionarios: un filete de 0.5pt se traza como línea fina en el PDF y en pantalla en lugar de redondearse a un píxel completo (el mínimo es 0.25px).

#Estilos de tabla con nombre

Un documento pocas veces compone todas sus tablas igual: una lista de comprobación en una cuadrícula azul marino con el marco redondeado, una fila de opciones encerrada solo por su marco exterior, una tabla de datos con filetes horizontales. tableStyles declara variantes con nombre, y un recurso de tabla elige una con table.styleId. Cada campo que un estilo deja sin definir se lee primero de tableStyle y después del texto de cuerpo, de modo que un estilo solo indica lo que distingue a sus tablas. Una tabla sin styleId, o con un id que ningún estilo declara, conserva tableStyle: un documento sin tableStyles se compone exactamente igual que antes.

const config: PostextConfig = {
  tableStyle: {
    borderColor: { hex: '#163a76', model: 'hex' },
    borderWidth: { value: 1.3, unit: 'pt' },
    borderRadius: { value: 10, unit: 'pt' },
  },
  tableStyles: [
    {
      id: 'option',
      name: 'Fila de opciones',
      rules: 'outer',
      borderColor: { hex: '#7a9cc6', model: 'hex' },
      borderWidth: { value: 1, unit: 'pt' },
      borderRadius: { value: 8, unit: 'pt' },
      headerBackgroundEnabled: false,
    },
  ],
};
 
// En los recursos: esta tabla se compone con el estilo "option".
const resource: Resource = {
  id: 'choices', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  table: { model: { rows: [/* … */] }, styleId: 'option' },
};

Cada entrada admite todos los campos de tableStyle más id (lo que referencia table.styleId) y un name opcional para el editor (por defecto, el id). Todo lo que un estilo puede definir se aplica por tabla: tipografía, fondos, bordes, filetes, radio de las esquinas, relleno y el comportamiento de desbordamiento con sus cadenas de continuación. resolveTableStylesConfig(styles, tableStyle, resolvedBodyText, locale?) devuelve la lista resuelta, pickTableStyle(resolved, styleId) el estilo con el que se compone una tabla, y stripTableStylesDefaults elimina los campos sin definir (conserva un campo igual a su valor por defecto, que sigue sustituyendo a un valor distinto de tableStyle).

#Tablas más altas que la página

Una tabla flotante que no cabe en la página nueva que se le ofrece no se comprime ni se desborda: con overflow: 'split' (el valor por defecto) el motor la corta entre filas en el último borde que cabe en la página y la continúa en las páginas siguientes, tantas como haga falta. Cada parte continuada repite las filas de cabecera de la tabla (TableModel.headerRowCount, o las filas iniciales formadas por celdas de cabecera cuando no está definido) y lleva de nuevo el pie con continuedSuffix tras la descripción: «Tabla 6-4. Título (cont.)». Cada parte que sigue lleva continuesMarker debajo, alineado a la derecha, con la tipografía de la nota; la nota de la tabla se reserva para la última parte. Un corte nunca atraviesa una celda combinada (una celda con rowSpan pasa entera a la parte siguiente), y una fila que encabeza a las que la siguen —una sola celda a lo ancho de toda la tabla— pasa a la parte siguiente en lugar de quedar suelta al pie de la página.

'clip' conserva las filas iniciales que caben en la página y descarta el resto en silencio (la nota sigue cerrando la parte); 'hide' no coloca la tabla. Ambos solo actúan cuando la tabla es más alta que una página: una tabla que cabe se coloca entera en cualquier modo. Las tablas en línea (placement.position: 'here') no se dividen.

Las cadenas de continuación toman su valor por defecto del idioma del documento (locale, o en su defecto el idioma de la división silábica): inglés (cont.) / Continued, español (cont.) / Continúa.

El contenido de una celda es markdown en línea, y un salto de línea dentro de la celda abre un nuevo párrafo. Un párrafo que empieza por una viñeta o un guion (, -, *, ) o por un número (1., 1)) seguido de un espacio se compone como elemento de lista: la marca se pinta tal como se escribió, el texto cuelga de ella a la distancia unorderedLists.gap del documento, las líneas partidas se alinean con el texto y dos espacios iniciales anidan un nivel. Así, una celda escrita como • Ofrece elección\n• Acomoda a personas diestras y zurdas sale como una lista de dos elementos.

Los anchos de columna pertenecen al modelo de la tabla, no al estilo: TableModel.columnWidths es un array opcional de pesos relativos, uno por columna, normalizados en el momento de maquetar — [2, 1, 1] da a la primera columna la mitad del ancho. Un array ausente, de longitud incorrecta o con algún peso no positivo vuelve al reparto igualitario. El editor de tablas mantiene el array alineado al añadir o quitar columnas.

#Estilo de pies de recurso

La propiedad captionStyle controla los pies de recurso (la línea Figura 1 — … bajo — o sobre — imágenes, SVG y tablas). La etiqueta numerada y la descripción comparten tipografía y tamaño — una limitación del motor — pero la etiqueta puede llevar su propio peso, cursiva y color. La familia tipográfica, el tamaño y el color heredan del texto de cuerpo cuando no se indican. El pie puede situarse encima del recurso (la convención habitual para tablas) y componerse sobre una barra de color que ocupa todo el ancho del bloque; una nota opcional más pequeña (fuente, créditos — Resource.note) se estiliza mediante el subobjeto note. Un tipo de recurso puede sobrescribir cualquiera de estos campos para sus propios recursos mediante ResourceType.captionStyle (ver Tipos de recurso).

const config: PostextConfig = {
  captionStyle: {
    align: 'center',
    labelBold: true,
    labelColor: { hex: '#295AA3', model: 'hex' },
    descriptionItalic: true,
    position: 'above',
    backgroundEnabled: true,
    padding: { value: 0.35, unit: 'em' },
    note: { italic: true, align: 'left' },
  },
};
PropiedadTipoPor defectoDescripción
fontFamilystringfuente del cuerpoFamilia tipográfica del pie (etiqueta y descripción).
fontSizeDimensiontamaño del cuerpoTamaño de fuente del pie (etiqueta y descripción).
colorColorValuecolor del cuerpoColor del texto de la descripción.
align'left' | 'center' | 'right''left'Alineación horizontal del pie bajo el recurso.
gapDimension0.75emSeparación vertical entre el recurso y su pie.
labelBoldbooleantrueRenderizar la etiqueta numerada (p. ej. Figura 1) en negrita.
labelItalicbooleanfalseRenderizar la etiqueta numerada en cursiva.
labelColorColorValuecolor del pieColor de la etiqueta numerada.
descriptionItalicbooleanfalseRenderizar el texto de la descripción en cursiva.
position'above' | 'below''below'Dónde se sitúa el pie. Con 'above' el pie (y su barra) va primero y el cuerpo del recurso baja la altura del pie más gap; la nota pasa entonces bajo el cuerpo.
backgroundEnabledbooleanfalsePintar una barra tras el pie. La barra ocupa todo el ancho del bloque y envuelve las líneas del pie más padding por cada lado.
backgroundColorValuecolor principal de la paletaColor de relleno de la barra (solo se pinta cuando está activada).
paddingDimension0.35emRelleno interior entre el borde de la barra y el texto del pie. Se ignora si la barra está desactivada.
noteobjectEstilo de la nota del recurso — ver la subtabla siguiente.

El subobjeto note estiliza Resource.note, una línea breve (fuente, créditos, una observación) compuesta bajo el recurso en un tamaño menor. Admite el mismo formato en línea y las mismas marcas :ref que el pie, y hereda la tipografía del pie. Se coloca bajo el pie cuando este va debajo, y bajo el cuerpo del recurso cuando el pie va encima; su altura cuenta en el bloque, de modo que un recurso con nota flota como una sola unidad.

PropiedadTipoPor defectoDescripción
note.fontSizeDimension0.85 × tamaño del pieTamaño de fuente de la nota.
note.colorColorValuecolor del pieColor del texto de la nota.
note.italicbooleanfalseRenderizar la nota en cursiva.
note.gapDimension0.35emSeparación entre la nota y lo que la precede (pie o cuerpo).
note.align'left' | 'center''left'Alineación horizontal de la nota.

Las sobrescrituras por tipo se combinan con mergeCaptionStyle(resolvedCaptionStyle, override, palette?), exportada para los hosts que necesiten la misma resolución fuera del pipeline.

#Estilo de diagramas

La propiedad diagramStyle controla cómo se colorean los diagramas SVG incrustados (recursos con kind: 'svg'). Su única función hoy es el modo a una sola tinta: una pasada de recoloreado que convierte cada color del diagrama en un matiz de una única tinta, de modo que las figuras se reproduzcan fielmente cuando el documento se imprime con una sola tinta plana.

const config: PostextConfig = {
  diagramStyle: {
    singleInk: true,
    inkColor: { hex: '#295AA3', model: 'hex' },
  },
};
PropiedadTipoPor defectoDescripción
singleInkbooleanfalseRecolorear todos los diagramas SVG incrustados a matices de una única tinta.
inkColorColorValueColor principal (#295AA3)La tinta. Por defecto es el color principal de la paleta del documento (enlazado mediante paletteId: 'main-color'), de modo que cambiar la muestra de la paleta retintea los diagramas junto con los encabezados y las negritas.

#Cómo funciona la tinta única

Cuando singleInk está activado, cada color del marcado SVG se reescribe como un matiz de inkColor cuya intensidad es 1 − luminancia relativa (coeficientes Rec. 709 aplicados a los canales con codificación gamma — una aproximación perceptual más que suficiente para el mapeo de matices). El mapeo preserva el valor percibido: el blanco se convierte en el blanco del papel, el negro en la tinta plena, y los rellenos claros siguen siendo claros con independencia de su tono original. Un fondo amarillo pálido pasa a ser un matiz pálido de la tinta; un trazo oscuro se acerca a la tinta plena.

El recoloreado lo realiza la función exportada applySingleInkToSvg(svgText, inkHex), que opera sin DOM, directamente sobre el marcado SVG como texto:

  • Los literales hexadecimales #rgb / #rgba / #rrggbb / #rrggbbaa y las funciones rgb() / rgba() se reescriben dondequiera que aparezcan — atributos de presentación, style en línea, degradados, <defs>.
  • Las palabras clave white y black solo se sustituyen cuando aparecen como valores de pintado (fill, stroke, stop-color, flood-color, color — como atributos o propiedades de estilo en línea), nunca dentro del contenido de texto ni de las etiquetas.
  • none, transparent y currentColor se dejan intactos.
  • Los canales alfa se preservan (los dígitos de #rgba / #rrggbbaa y el componente alfa de rgba(…) viajan sin cambios).
  • Cuando inkHex no se puede interpretar, la entrada se devuelve sin cambios.
import { applySingleInkToSvg } from 'postext';
 
const recoloreado = applySingleInkToSvg(svgText, '#295AA3');

La tinta única se aplica en los tres backends: el viewport canvas y el visor HTML recolorean el marcado SVG antes de rasterizarlo, y el backend PDF vuelve a rasterizar los bytes del recurso SVG con la tinta aplicada, de modo que el PDF exportado coincide con la previsualización en pantalla.

El resolver y el stripper siguen el mismo patrón que las demás secciones, junto con los tipos DiagramStyleConfig / ResolvedDiagramStyleConfig:

import {
  DEFAULT_DIAGRAM_STYLE_CONFIG,
  resolveDiagramStyleConfig,
  stripDiagramStyleDefaults,
  applySingleInkToSvg,
} from 'postext';
import type { DiagramStyleConfig, ResolvedDiagramStyleConfig } from 'postext';
 
const resolved = resolveDiagramStyleConfig(config.diagramStyle);
// => { singleInk: false, inkColor: { hex: '#295AA3', model: 'hex', paletteId: 'main-color' } }
 
const minimal  = stripDiagramStyleDefaults(config.diagramStyle);
// => undefined cuando todo coincide con los valores por defecto

#Estilos de párrafo

La propiedad paragraphStyles declara estilos con nombre que el documento aplica a una serie de párrafos mediante un contenedor :::paragraphs{style="…"} — bibliografías, glosarios, notas, cualquier bloque de entradas que quiera su propia tipografía, tamaño, interlineado o un sangrado francés. Todos los campos tipográficos son opcionales y heredan del texto de cuerpo cuando no se indican, de modo que un estilo solo describe lo que difiere del texto corrido.

const config: PostextConfig = {
  paragraphStyles: [
    {
      id: 'bibliography',
      name: 'Bibliografía',
      fontSize: { value: 7, unit: 'pt' },
      lineHeight: { value: 1.2, unit: 'em' },
      hangingIndent: { value: 2, unit: 'em' },
      spaceBetween: { value: 0.25, unit: 'em' },
      marginTop: { value: 1, unit: 'em' },
      marginBottom: { value: 1, unit: 'em' },
    },
  ],
};
## Referencias
 
:::paragraphs{style="bibliography"}
Knuth, D. E. (1984). *The TeXbook*. Addison-Wesley.
 
Bringhurst, R. (2004). *The Elements of Typographic Style*. Hartley & Marks.
:::
PropiedadTipoPor defectoDescripción
idstringobligatorioIdentificador al que hace referencia :::paragraphs{style="…"}.
namestringidNombre legible, solo para interfaces de edición.
fontFamilystringfuente del cuerpoFamilia tipográfica. Los pesos (regular y negrita) siguen al texto de cuerpo.
fontSizeDimensiontamaño del cuerpoTamaño de fuente.
lineHeightDimensioninterlineado del cuerpoInterlineado. em/rem son relativos al tamaño del propio estilo, así que un 1.5em heredado se estrecha junto con un tamaño menor.
colorColorValuecolor del cuerpoColor del texto. Las negritas y cursivas conservan los colores de énfasis del cuerpo.
textAlign'left' | 'justify' | 'center' | 'right'alineación del cuerpoAlineación horizontal. 'center' y 'right' dejan cada línea en bandera por el otro lado — una dedicatoria, una firma.
boldColorColorValuebodyText.boldColorColor de las negritas (una lista de autores con los nombres en el color de la casa).
hyphenationbooleanseparación del cuerpoSeparar sílabas al justificar (usa el idioma del documento).
firstLineIndentDimensionsangría del cuerpoSangría de la primera línea. Se ignora cuando hangingIndent no es cero.
hangingIndentDimension0Sangría aplicada a todas las líneas salvo la primera — la forma clásica de bibliografías y glosarios.
spaceBetweenDimension0Separación vertical entre párrafos consecutivos dentro del contenedor. Con 0 las entradas quedan pegadas.
marginTopDimension0Espacio sobre el primer párrafo del contenedor. Colapsa con el espaciado ya pendiente y desaparece al inicio de una columna, como cualquier otro margen.
marginBottomDimension0Espacio mínimo bajo el último párrafo del contenedor.

#El contenedor :::paragraphs

Una línea :::paragraphs{style="<id>"} abre el contenedor y una línea ::: a solas lo cierra; todos los párrafos intermedios toman el estilo indicado, mientras que los encabezados, listas y otros bloques del interior conservan su estilo habitual. Los contenedores pueden anidarse dentro de otros contenedores. Un style desconocido no es un error: los párrafos se componen como texto de cuerpo normal.

Dentro del contenedor el flujo abandona la rejilla base — una entrada de 7pt con interlineado 1.2em no puede asentarse en una rejilla de 8pt/1.5em — y el último párrafo devuelve el flujo a la rejilla, garantizando al menos marginBottom bajo el texto (la rejilla manda; el margen es un mínimo, la misma convención que siguen los encabezados). Las entradas se parten entre columnas y páginas como los párrafos del cuerpo, con la misma protección de viudas y huérfanas; un encabezado inmediatamente anterior al contenedor se mantiene unido a su primer párrafo.

const resolved = resolveParagraphStylesConfig(config.paragraphStyles, resolvedBodyText);
// => cada campo no indicado se rellena desde el texto de cuerpo resuelto
 
const minimal  = stripParagraphStylesDefaults(config.paragraphStyles);
// => undefined cuando la lista está vacía; se eliminan los márgenes a cero y `name === id`

#Estilos de chip

La propiedad chipStyles declara los estilos con nombre del :chip[texto]{style="…"} en línea: las cajas redondeadas y tintadas de un banco de palabras, una tecla, una etiqueta (la sintaxis y sus reglas de corte de línea están en la referencia del formato de documento). Viene un estilo por defecto, chip (relleno azul pálido con un filete del color principal, esquinas algo redondeadas y el texto como las palabras que lo rodean), así que :chip[…] funciona sin configurar nada; declarar chipStyles sustituye esa lista. Un chip sin style, o con un id que ningún estilo declara, toma el primer estilo.

const config: PostextConfig = {
  chipStyles: [
    { id: 'chip', name: 'Banco de palabras' },
    {
      id: 'key',
      name: 'Tecla',
      background: { hex: '#fff4d6', model: 'hex' },
      borderColor: { hex: '#8a6d1f', model: 'hex' },
      borderRadius: { value: 2, unit: 'pt' },
      bold: true,
    },
  ],
};
Clasifica: :chip[pila] :chip[cable] :chip[interruptor]
 
Pulsa :chip[Ctrl]{style="key"} + :chip[C]{style="key"}.
PropiedadTipoPor defectoDescripción
idstringobligatorioIdentificador que se usa en :chip[…]{style="…"}.
namestringidNombre legible, solo para interfaces de edición.
backgroundEnabledbooleantruePinta el relleno de la caja.
backgroundColorValue#e8eef7Relleno de la caja (vinculable a la paleta).
borderColorColorValuecolor principal de la paletaColor del contorno.
borderWidthDimension0.5ptGrosor del contorno; 0 no dibuja ninguno. Se traza por dentro del borde de la caja.
borderRadiusDimension0.3emRadio de las esquinas, limitado a la mitad de la altura de la caja (un valor grande da una píldora).
paddingXDimension0.3emEspacio entre el contorno y el texto, a izquierda y derecha. Forma parte del avance del chip.
paddingYDimension0.1emEspacio por encima y por debajo de la banda del texto. Se pinta fuera de la caja de línea: nunca cambia el interlineado.
fontFamilystringtexto que lo rodeaFamilia del texto del chip. Los pesos siguen al texto que lo rodea.
fontSizeDimensiontexto que lo rodeaCuerpo del texto del chip; em es relativo al texto que lo rodea.
colorColorValuetexto que lo rodeaColor del texto del chip. Sin fijar, las negritas y cursivas conservan sus colores de énfasis.
boldbooleanfalseCompone el texto del chip en negrita, además de sus propias marcas.
italicbooleanfalseCompone el texto del chip en cursiva, además de sus propias marcas.
gapDimension0.25emEspacio mínimo entre la caja y la palabra o el chip vecino a través de un espacio; un espacio más estrecho se completa dentro del avance del chip, así que la justificación nunca lo come. No se añade nada en el borde de la línea ni junto a la puntuación pegada.

Las longitudes en em de la caja (paddingX, paddingY, borderRadius, borderWidth, gap) son relativas al cuerpo del propio chip. La caja es una banda de 0,8 em por encima y 0,25 em por debajo de la línea base, que crece con paddingY y el contorno; se pinta fuera de la caja de línea y nunca cambia el interlineado, así que la retícula se mantiene. Cuando la caja resulta más alta que el interlineado, los chips de líneas seguidas se tocan: el sandbox muestra el aviso «Los chips tocan la línea siguiente» con el exceso, para reducir paddingY, el contorno o fontSize.

En el VDT un chip es un segmento de línea de kind: 'chip' cuyo campo chip lleva los tramos de texto (cada uno con su cadena de fuente y su ancho), la geometría de la caja (boxWidth, ascent, descent, paddingX, borderWidth, borderRadius, los márgenes del gap) y sus colores; el text del segmento es un marcador de un carácter, de modo que los desplazamientos de texto plano y los mapas de fuente cuentan un chip como un carácter.

const resolved = resolveChipStylesConfig(config.chipStyles);
// => el estilo `chip` integrado si no se declara; todos los campos rellenos
 
const minimal  = stripChipStylesDefaults(config.chipStyles);
// => undefined para el valor integrado; se eliminan los valores por defecto
 
const style = pickChipStyle(resolved, 'key');
// => el estilo `key`, o el primero

#Estilos de aviso

La propiedad calloutStyles declara los estilos de caja con nombre que el documento aplica mediante un contenedor :::callout{type="…"} — notas, consejos, advertencias, objetivos de aprendizaje, cualquier contenido separado del texto corrido en una caja tintada o con borde. Por defecto se incluye un estilo neutro, note (fondo gris claro, sin borde, sin franja, sin icono, sin título), de modo que :::callout funciona sin configuración alguna; declarar calloutStyles sustituye esa lista por defecto.

const config: PostextConfig = {
  calloutStyles: [
    { id: 'note', name: 'Nota' },
    {
      id: 'objectives',
      name: 'Objetivos de aprendizaje',
      title: 'Objetivos',
      stripe: { enabled: true, side: 'left' },
      icon: { kind: 'glyph', glyph: '✓' },
      titleStyle: { textTransform: 'uppercase' },
      lists: { bulletChar: '–' },
    },
    {
      id: 'warning',
      title: 'Advertencia',
      backgroundEnabled: false,
      border: { enabled: true, color: { hex: '#AA0000', model: 'hex' }, width: { value: 1, unit: 'pt' } },
      borderRadius: { value: 1, unit: 'mm' },
      titleStyle: { color: { hex: '#AA0000', model: 'hex' } },
    },
  ],
};
:::callout{type="objectives"}
- Describir las partes de la linterna.
- Recortar la mecha al anochecer.
:::
 
:::callout{type="warning" title="No toques la lente"}
El cristal sigue caliente una hora después de apagar la llama.
:::
PropiedadTipoPor defectoDescripción
idstringIdentificador que selecciona :::callout{type="…"}. Una valla con un type desconocido o ausente usa el primer estilo configurado (el sandbox avisa de los tipos desconocidos).
namestringidNombre legible (solo para la interfaz del editor).
titlestring''Texto de título por defecto; vacío significa sin título. El atributo title de la valla lo sobrescribe en cada instancia.
span'column' | 'page' | 'side''column'Extensión horizontal: la columna, el ancho completo del área de contenido o la columna lateral solo para flotantes de una disposición de columna y media (layout.sideColumnRole: 'floats') — la caja sale entonces del flujo y se apila en esa columna junto al texto que interrumpe. Se puede sobrescribir por instancia con el atributo span. En disposiciones multicolumna una caja 'page' se convierte en un bloque a ancho de página: divide la página en bandas de columnas y ocupa su propia columna a todo el ancho (ver la sección del contenedor más abajo).
placement'here' | 'auto' | 'top' | 'bottom' | 'fixed''here'Dónde va la caja. 'here' la deja en línea en el flujo; 'top' / 'bottom' la hacen flotar como un recurso ('auto' toma la primera banda que quede libre, la cabecera o el pie) — sale del flujo donde aparece y ocupa la primera banda libre en ese punto o después (el pie de la página actual, o la cabecera / el pie de la siguiente página que abre el flujo), y el texto que la sigue rellena la página a su alrededor; 'fixed' la ancla a coordenadas de página mediante fixed, fuera del flujo de columnas. Ver la sección del contenedor para los detalles. Se puede sobrescribir por instancia con el atributo placement.
fixedPosición de una caja 'fixed': un ElementAnchor (to: 'container' = el área de contenido de la página, reflejada en las páginas pares; 'page' = la caja de corte; 'bleed' = la caja de sangre; edge: uno de los nueve bordes del contenedor) más un offset opcional (dimensiones x / y).
floatBarrierbooleanfalseConvierte la caja en una barrera de flotantes: toda figura o tabla referenciada antes se coloca antes que ella — en los huecos libres de la página, o en páginas abiertas por delante de la caja —, de modo que ningún flotante escapa más allá de la caja que cierra el capítulo (normalmente un resumen de «puntos clave»). Las aperturas de capítulo, los :::part y el final del documento son siempre barreras.
Una caja span: 'page' en una página a varias columnas corta la banda bajo el texto que la precede; una figura a todo el ancho referenciada antes toma ese corte primero: el texto se nivela, la figura queda justo donde terminó y la caja sigue debajo (o pasa a la página siguiente cuando ya no cabe). Una figura demasiado alta para seguir al texto nivelado abre la página siguiente, con la caja tras ella, y la banda que deja sigue terminando nivelada. Una caja divisible (keepTogether: false) se abre bajo el texto y la figura con los elementos que caben, y el resto continúa en la página siguiente.
width'fill' | 'auto''fill''fill' ocupa todo el ancho disponible; 'auto' se ajusta al título (uso como etiqueta) e ignora los hijos.
backgroundEnabled / backgroundboolean / ColorValuetrue / #f4f4f4Relleno de la caja.
border{ enabled, color, width }false, #cccccc, 0.5ptContorno de la caja.
borderRadiusDimension0Radio de las esquinas del fondo / borde.
padding{ top, right, bottom, left }0.75em cada unoMargen interior entre el borde de la caja y su contenido. Los valores en em son relativos al tamaño del cuerpo del aviso.
stripe{ enabled, side, width, color }false, 'left', 1.5em, color principalFranja sólida a lo largo de un lado. Una franja 'left' / 'right' estrecha el contenido; una franja 'top' lo desplaza hacia abajo.
icon{ kind, glyph, resourceId, fontFamily, fontWeight, size, width, color, align, position, cornerSide }'none', fuente de encabezados, 400, 1.5em, color principal, 'top'Un glifo de texto (kind: 'glyph') o un recurso bitmap / SVG (kind: 'resource' + resourceId) junto al contenido. Con franja lateral el icono se centra sobre la franja; si no, reserva su propia columna (size + titleStyle.gap). align: 'center' lo centra verticalmente sobre el contenido. Una imagen de recurso se ajusta dentro del cuadrado conservando su proporción; un icono más alto que el contenido agranda la caja para contenerlo (y, con align: 'center', centra el contenido sobre él). Con width la imagen se ajusta a una caja de width × size (una tira ancha de iconos). position: 'corner' cuelga el icono de una esquina superior como una insignia, medio fuera del borde y sin ocupar sitio en el contenido; cornerSide elige la esquina: 'right' / 'left', o 'outer' / 'inner', que siguen la paridad de la página con márgenes simétricos (exterior = derecha en una página impar, izquierda en una par).
marker{ kind, glyph, resourceId, fontFamily, fontWeight, size, color, align, gap, rule }'none', fuente de títulos, 400, 1.5em, color principal, 'center', 0.5em, filete desactivado (0.5pt, color principal, longitud 0)Un segundo icono dibujado fuera de la caja, en una columna a su izquierda, con un rule (filete vertical) opcional entre él y la caja: la mano de «toca aquí» junto a un distintivo de autoevaluación. El marco pasa a ser [marker][rule][gap][box] y mide lo que el más alto de los tres; align los centra entre sí o los alinea arriba. rule.length es un mínimo: el filete abarca siempre al menos la altura de la caja.
titleStyle{ fontFamily, fontSize, fontWeight, italic, color, textTransform, gap, letterSpacing, indent }fuente de encabezados, tamaño del cuerpo, 700, false, color principal, 'none', 0.5emTipografía del título. gap es el espacio entre el título y el primer hijo (y el hueco de la columna del icono). textTransform: 'uppercase' conserva la longitud del texto. letterSpacing aplica un tracking al título (canvas letterSpacing / PDF Tc); indent lo desplaza a la derecha del borde interior de la caja. Una insignia de esquina que cuelga del lado del título (la esquina izquierda) reserva antes su propio sitio — su mitad interior más gap — de modo que el título la libra caiga en la página que caiga; indent solo añade a partir de ahí.
body{ fontFamily, fontSize, lineHeight, color, boldColor, italicColor, textAlign, hyphenation, paragraphSpacing, firstLineIndent }hereda de bodyTextTipografía de los párrafos dentro de la caja. Cada campo hereda del texto de cuerpo cuando no se indica; italicColor fija el color de las cursivas (una cita destacada en cursiva con el color de la caja).
lists{ bulletChar, color, indent, gap, itemSpacing, bulletFontSize, bulletFontWeight }hereda de unorderedListsTipografía de las listas dentro de la caja (color, indent, gap e itemSpacing se aplican también a las listas ordenadas). bulletFontSize / bulletFontWeight componen la viñeta en la fuente del cuerpo de la caja a ese tamaño y peso (una viñeta gruesa de color).
label{ fontFamily, fontSize, fontWeight, color, background, position, height, paddingX, offset, inset, icon, rule }sin definir (sin pestaña)Una pestaña sobre el borde superior de la caja que imprime el atributo label de la valla —el número de un recuadro numerado («RECUADRO 1-1»)—. Se pega a la esquina position ('top-right' / 'top-left') con un retranqueo inset, sobresale offset por encima del borde (ese espacio forma parte del bloque, además de marginTop, y se conserva también en cabeza de columna), mide height de alto con paddingX a cada lado del texto, y puede llevar un recurso icon a su lado ({ resourceId, width, gap }, hacia el interior) y un filete rule ({ enabled, color, width }) a lo largo del borde superior desde la esquina opuesta hasta ella. Por defecto: fuente de encabezados, tamaño del cuerpo, 700, blanco sobre el color principal, 1.4em de alto, 0.6em de relleno.
columnGapDimension1.5emSeparación entre las columnas de un grupo :::columns dentro de la caja (véase la sección del contenedor).
marginTop / marginBottomDimension0.75em / 0.75emEspacio sobre la caja (se funde con el margen del bloque anterior) y espacio mínimo bajo ella (el espacio exacto con snapToGrid: false).
snapToGridbooleantrueCon true el flujo vuelve a la rejilla base tras la caja, de modo que el espacio bajo ella es marginBottom redondeado hacia arriba a líneas enteras de la rejilla. Con false la caja conserva su marginBottom exacto, que se funde con el margen superior del bloque siguiente — dos cajas consecutivas de ese estilo quedan exactamente a max(marginBottom, marginTop) — y el texto que la sigue puede quedar fuera de la rejilla hasta el siguiente punto de ajuste (un encabezado, el final de una lista), como tras un encabezado con headings.snapToGrid: false. Pensado para documentos hechos de cajas apiladas (fichas, formularios). Se aplica a las cajas del flujo; las cajas a ancho de página en una maquetación de varias columnas, las flotantes, las fijas y las laterales conservan la rejilla, porque las bandas de columnas y las zonas de flotantes se disponen sobre ella. Las palancas de equilibrado de columnas no cambian: una caja que cierra una columna sigue bajando hasta la última línea de la rejilla de esa columna.
keepTogetherbooleantrueCon true la caja se mantiene entera: un aviso que no cabe en el espacio restante pasa entero a la columna o página siguiente. Solo una caja más alta que una columna completa y vacía — una página entera en una caja span: 'page' — no puede mantenerse entera: se parte con las reglas de false que siguen en lugar de desbordar, empezando donde aparece, y una continuación que cabe en una columna pasa entonces entera; una caja flotante (placement: 'top' | 'bottom' | 'auto') así de alta no flota, sino que sigue en el flujo donde aparece. Con false cualquier caja puede partirse entre sus bloques hijos o entre las líneas de un párrafo o de un elemento de lista: el corte más profundo que cabe cierra la columna actual (o, en una caja span: 'page', la página, a ras del pie de las columnas) y el resto continúa al inicio de la siguiente en una caja propia — mismo marco y franja, sin título ni icono —, partiéndose de nuevo si sigue siendo demasiado alta. Un corte dentro de un elemento de lista deja la viñeta con la cabeza. El marco de cada fragmento comparte el contentIndex / containerId de la valla y registra callout.part / callout.continued. Úsalo en una caja de «puntos clave» larga de cierre junto con headings.balancing.beforeSpan, o en un estilo de nota cuyas cajas nunca deban expulsar una figura de la página. Una caja anidada (un :::callout dentro de otro) es un hijo más de su madre: un corte puede caer antes o después de ella, y dentro solo si su propio estilo permite partirla (keepTogether: false, o más alta que una columna completa), según su propio splitMinLines.
splitMinLinesnumber2Mínimo de líneas de texto que un fragmento de una caja partida (keepTogether: false, o una caja unida más alta que una columna completa) conserva a cada lado del corte. Solo protege el texto: un lado con al menos una figura, tabla, fórmula en bloque o caja anidada es válido tenga las líneas que tenga, de modo que una caja de imágenes puede dejar una sola en una página. Un corte dentro de un párrafo o de un elemento de lista sigue contando las líneas de cada lado (una figura o fórmula, como una línea). Con el valor por defecto ninguna caja se parte dejando una línea de texto sola al pie de una columna o al inicio de la siguiente; si ningún corte cumple el mínimo, la caja pasa entera.

#El contenedor :::callout

Una línea :::callout{type="<id>"} abre la caja y una línea ::: a solas la cierra. La valla admite cuatro atributos — type (el id del estilo), title (sobrescribe el título del estilo), span y placement (sobrescriben los valores del estilo) — y el contenido intermedio se compone dentro de la caja: un título opcional y después los párrafos, listas, citas, fórmulas o recursos incrustados, cada uno con la tipografía body / lists del estilo (los encabezados conservan sus estilos habituales). Los márgenes entre hijos se funden como en el texto corrido; el interior abandona la rejilla base y el flujo vuelve a ella tras la caja garantizando al menos marginBottom por debajo (la rejilla manda; el margen es un mínimo — la misma convención que siguen los recursos). Un estilo con snapToGrid: false conserva en cambio el marginBottom exacto, y el texto tras la caja queda fuera de la rejilla hasta el siguiente encabezado o final de lista. Un encabezado inmediatamente anterior a un aviso se mantiene unido a él.

Límites de esta versión:

  • Un aviso se mantiene entero salvo que su estilo indique keepTogether: false. Cuando no cabe en el espacio restante de la columna pasa entero a la columna o página siguiente — también desde una columna vacía que las bandas de flotantes o un tope de banda han acortado, siempre que una columna completa lo alojara. Una caja más alta que una columna completa se parte en su lugar, como una divisible; solo la que ningún corte puede partir (una figura, tabla o grupo :::columns más alto que la columna, o un splitMinLines que ningún corte cumple) se coloca de todos modos y desborda; la maquetación registra entonces un aviso calloutOverflow (VDTDocument.warnings), que el sandbox lista. Una caja divisible deja atrás la parte que cabe — hijos enteros, o las líneas de un párrafo hasta splitMinLines a cada lado (basta una figura, tabla o fórmula en bloque en un lado) — y continúa en una caja sin título ni icono en la columna o página siguiente.
  • Los flotantes ceden ante una caja indivisible. Cuando el bloque que sigue a la referencia de una figura es un aviso keepTogether, no se toma un hueco que dejaría a la caja sin ninguna columna de la banda actual donde caer (la columna de la referencia o una vacía posterior, que antes del flotante la alojaba): la figura pasa a su siguiente hueco, normalmente la página siguiente, y la caja sigue en el flujo — como la compondría un cajista, en lugar de expulsar la caja de la página y dejar la columna con la figura sola.
  • span: 'page' en una disposición multicolumna convierte la caja en un bloque a ancho de página: se compone al ancho completo del área de contenido y divide la página en bandas de columnas — las columnas de texto que hay encima se cierran en la línea de corte, la caja ocupa su propia columna a todo el ancho y debajo se abre una banda nueva de columnas de texto, de modo que el flujo continúa bajo la caja en todas las columnas. Donde las columnas están niveladas — al inicio de una página, justo debajo de un encabezado de apertura con span: 'page', justo debajo de otro bloque a ancho de página o justo debajo de una banda de flotantes superior — la caja simplemente corta ahí. Si llega a mitad de página, con las columnas desiguales, se compone como lo haría un cajista: el texto que hay encima se corta nivelado en todas las columnas (el motor repite la colocación con las columnas de la banda acortadas al mismo número de líneas de rejilla, de modo que el texto desborda de columna en columna de forma natural y siguen aplicándose todas las reglas de huérfanas, viudas y encabezados unidos a su texto), la caja ocupa el ancho de la página y las columnas continúan debajo. El corte cuesta un par de pasadas de colocación adicionales; cuando la línea de corte no dejaría sitio para la caja más el mínimo de líneas viudas de cuerpo por debajo, o ninguna disposición encaja tras unos pocos intentos, la caja pasa al inicio de la página siguiente. Con headings.balancing.beforeSpan (el valor por defecto) la banda que deja se corta nivelada tras ella — como la banda de cierre de un capítulo — y, cuando el estilo permite partirla (keepTogether: false), la parte de la caja que cabe bajo las columnas niveladas cierra la página y el resto abre la siguiente; con beforeSpan: false la página que deja simplemente se equilibra como siempre, sin forzar un salto de página. Un encabezado justo antes de un bloque a ancho de página no viaja con él. En disposiciones de una columna span: 'page' es simplemente en línea.
  • placement: 'fixed' saca la caja del flujo: se compone (width: 'auto' se ajusta al título; 'fill' toma el ancho de la columna de texto bajo el punto de anclaje) y se fija a la página en la que aparece en el flujo, en la posición que describen fixed.anchor / fixed.offset — por defecto la esquina inferior izquierda del área de contenido. Las columnas de texto que cubre ceden esa zona (recortada por abajo, o por arriba cuando la columna aún está vacía), exactamente como una banda de flotantes; cuando la zona ya contiene texto, un flotante o un bloque a ancho de página, la caja pasa a la página siguiente. Una caja fija que cierra el capítulo (el bloque siguiente es una apertura de capítulo, un :::part, una caja barrera de flotantes o el final del documento) nivela primero las columnas que quedan encima (headings.balancing.trailing), de modo que una página de cierre corta termina nivelada con el distintivo debajo. La caja y sus hijos viven en page.floats y se pintan fuera del recorte de columna en todos los backends.
  • placement: 'top' | 'bottom' hace flotar la caja como un recurso: sale del flujo donde aparece y toma la primera banda libre a partir de ese punto — el pie de la página actual ('bottom'), o la cabeza / el pie de la siguiente página que abre el flujo — al ancho de la columna (span: 'column') o al ancho completo del área de contenido (span: 'page'); el texto que la sigue rellena la página que dejó. Su marco y sus hijos van a page.floats, como los de una caja fija. Una caja flotante que encabeza una página nueva se coloca antes que las figuras que esperan esa página, y una figura citada en una página anterior que entonces cabe bajo ella toma el resto de esa página aunque quedaran menos de tres líneas de texto (una página galería: caja más figura, sin texto entre ambas). Una caja span: 'side' nunca flota: se apila junto al texto sea cual sea su placement.
  • width: 'auto' se ajusta solo al título; los hijos se ignoran.
  • Un :::callout anidado dentro de otro aviso es una caja propia: se compone con su propio estilo (fondo, borde, radio, relleno, franja, título, icono, marcador, pestaña, tipografía) al ancho interior completo de su caja madre y se apila como un hijo más, con su marginTop / marginBottom colapsando con los vecinos. Su span y su placement (de la valla o del estilo) se ignoran —una caja anidada siempre fluye dentro de su madre—, igual que floatBarrier y snapToGrid. Las cajas se anidan a cualquier profundidad y pueden ir dentro de un grupo :::columns (cada una entera, en una columna). Cuando la caja madre se parte, el corte cae antes o después de una caja anidada, o dentro de ella si su estilo permite partirla; cada fragmento vuelve a dibujar los marcos que atraviesa el corte, y una caja anidada que continúa del fragmento anterior pierde su título y su icono, como una continuación de primer nivel.
  • Un grupo :::columns{count=N}::: entre los hijos compone esos hijos en N columnas de igual ancho, separadas por columnGap, dentro de la caja: la secuencia se corta por los límites de bloque o de línea que mejor nivelan las columnas (un párrafo o elemento de lista cortado a medias sigue en la cabeza de la columna siguiente sin su viñeta), cada columna empieza en la parte superior del grupo y el grupo mide lo que la columna más alta; los hijos posteriores recuperan el ancho completo. Una caja partible (keepTogether: false) nunca corta dentro de un grupo. Sirve para un resumen de puntos clave a dos columnas o para las tablas de un recuadro ancho puestas lado a lado.
  • El quinto atributo de la valla, label, se imprime en la pestaña label del estilo (véase arriba): :::callout{type="recuadro" label="RECUADRO 1-1" title="La regla del octeto"}; sin estilo de pestaña el atributo se ignora.

En el VDT la caja es un bloque marco de type: 'callout' cuya decoración (fondo, franja, icono, título) vive en designOverlay, seguido de sus bloques hijos en la misma columna; el marco y cada hijo llevan el containerId de la valla. Una caja anidada es un marco type: 'callout' propio entre los hijos, seguido de sus bloques; conservan el containerId de la valla de primer nivel (la colocación y el equilibrado siguen viendo una sola unidad) y añaden calloutPath, los identificadores de contenedor de las vallas anidadas que los rodean, de la más externa a la más interna (el de un marco anidado es la última entrada). El PDF etiquetado da a cada caja anidada un Div dentro del de su madre. Las imágenes de icono se resuelven como las de los recursos: el registro de imágenes del canvas, la opción resourceImageUrl del HTML y el proveedor resourceBytes del PDF.

const resolved = resolveCalloutStylesConfig(config.calloutStyles, resolvedBodyText, resolvedHeadings, resolvedUnorderedLists);
// => cada campo heredado se rellena desde las secciones resueltas
 
const minimal  = stripCalloutStylesDefaults(config.calloutStyles);
// => undefined para el `note` por defecto; se eliminan los valores estáticos por defecto

#Partes

La propiedad parts configura las páginas separadoras de parte que un documento abre con un contenedor :::part{number="…" title="…"} — la página «Parte I — Fundamentos» que agrupa una serie de capítulos. Una parte ocupa siempre una página propia: el contenedor salta a una página nueva con la paridad configurada, la convierte en una página de una sola columna cuya área de cuerpo sale de parts.margins, compone el diseño de apertura sobre la página entera y vuelve a saltar tras la valla de cierre para que el siguiente capítulo (con su propio breakBefore.parity) empiece limpio — con los valores por defecto de H1 eso produce la secuencia clásica: página de parte en recto, verso en blanco, capítulo en el siguiente recto.

const config: PostextConfig = {
  parts: {
    breakBefore: { parity: 'odd' },
    breakAfter: { enabled: true, parity: 'any' },
    margins: { top: { value: 9, unit: 'cm' }, left: { value: 3, unit: 'cm' }, right: { value: 3, unit: 'cm' } },
    design: {
      elements: [
        {
          kind: 'text', id: 'number', content: 'Parte {numberRoman}',
          fontSize: { value: 12, unit: 'pt' }, fontWeight: 600, align: 'left',
          placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: { value: 3, unit: 'cm' }, y: { value: 5, unit: 'cm' } }, size: { width: 'auto', height: 'auto' } },
        },
        {
          kind: 'text', id: 'title', content: '{titleText}',
          fontSize: { value: 28, unit: 'pt' }, fontWeight: 700, align: 'left', overflow: 'wrap',
          placement: { anchor: { to: '#number', edge: 'below' }, size: { width: { value: 15, unit: 'cm' }, height: 'auto' } },
        },
      ],
    },
    bodyStyle: { fontSize: { value: 11, unit: 'pt' }, numberColor: { hex: '#AA0000', model: 'hex' } },
  },
};
:::part{number="I" title="Fundamentos"}
1. El farol y sus partes
2. Recortar la mecha
3. Leer el tiempo
:::
 
# El farol y sus partes
PropiedadTipoPor defectoDescripción
pagebooleantrueSi un :::part abre una portadilla. Con false no se abre ninguna página ni se compone el cuerpo de la valla: el número, el título y la paleta de la parte rigen a partir del contenido siguiente, sin salto propio. Uso típico: htmlViewer.overrides.parts.page: false, una edición en pantalla sin portadillas.
breakBefore.parityHeadingBreakParity'odd'Paridad de la página en la que se abre la parte. Mismos valores y mismas reglas de pertenencia de las páginas en blanco que el breakBefore de los encabezados: un blanco insertado para alcanzar la paridad pertenece a la parte (su ya resuelve a la parte nueva); el separador obligatorio de 'always-*' pertenece al contenido anterior.
breakAfter.enabledbooleantruePasa el contenido posterior a la valla de cierre a una página nueva. Con false continúa en la columna única de la página de parte.
breakAfter.parityHeadingBreakParity'any'Paridad de esa página nueva. Déjalo en 'any' y deja que el propio breakBefore.parity del siguiente capítulo decida si sigue un verso en blanco. El salto se aplica al colocar el siguiente bloque, así que una parte que cierra el documento no deja una página vacía al final.
marginsPageMarginsmárgenes de páginaÁrea de cuerpo de la página de parte — la columna única en la que fluyen los bloques de dentro de la valla. Cada lado hereda el margen de página cuando no se define; mirror intercambia interior/exterior en las páginas pares exactamente como los márgenes de página.
designDesignSlotvacíoDiseño de apertura. Su contenedor es la caja de corte de la página, así que los anclajes al contenedor y a 'page' coinciden, y 'bleed' llega hasta el sangrado cuando las marcas de corte están activas. Puramente decorativo: nunca reserva espacio de cuerpo — sube margins.top para que el cuerpo no lo pise. Cuando está vacío se sintetiza un texto por defecto con la tipografía del H1 en la esquina superior izquierda del área de cuerpo.
versoDesignDesignSlotvacíoDiseño del verso en blanco que sigue a la página de parte (el reverso de la hoja separadora): mismo contenedor y marcadores que design. Déjalo vacío para un verso liso.
bodyStyle.fontFamily, fontSize, lineHeight, color, textAligncomo en bodyTextheredan de bodyTextTipografía de los párrafos, citas y elementos de lista de dentro de la valla. Los pesos, los colores de énfasis y la separación silábica vienen del texto de cuerpo.
bodyStyle.bulletColorColorValueunorderedLists.colorColor de las viñetas de las listas no ordenadas de dentro de la parte.
bodyStyle.numberColorColorValueorderedLists.colorColor de los números de las listas ordenadas de dentro de la parte. Los números van siempre en el peso de negrita del cuerpo, para que una lista de capítulos se lea como un índice.
bodyStyle.unorderedListsUnorderedListsConfigSobrescrituras parciales aplicadas sobre las unorderedLists del documento dentro de la parte, tras bulletColor. Los valores generales se propagan a los niveles que los heredaban; las entradas de levels se aplican solo a su nivel.
bodyStyle.orderedListsOrderedListsConfigSobrescrituras parciales aplicadas sobre las orderedLists del documento dentro de la parte, tras numberColor y el peso de negrita — p. ej. un separator '•' con su propia separatorFontFamily y separatorColor para la lista de capítulos de una apertura de parte.

#Marcadores del diseño de parte

El diseño resuelve el conjunto de marcadores de encabezado con los valores propios de la parte: {titleText} es el title de la valla; {number} el number tal como se escribió; {numberDecimal}, {numberRoman}, {numberRomanLower}, {numberAlpha}, {numberAlphaLower} lo reformatean — el número se interpreta como decimal o como numeral romano ("IV", "iv" y "4" dan {numberDecimal} = 4) y resuelven a '' para cualquier otra cosa. También están disponibles {partTitle} / {partNumber}, {chapterTitle} / {chapterNumber} (el capítulo anterior a la parte), {pageNumber}, {totalPages} y los marcadores de metadatos. {attr.<key>} lee los atributos del H1 del capítulo actual.

#El contenedor :::part

Una línea :::part{number="…" title="…"} abre la parte y un ::: a solas la cierra; los dos atributos son opcionales (por defecto ''). Los bloques intermedios — normalmente la lista de capítulos — fluyen en la columna única de la página de parte con bodyStyle, empezando en margins.top; un cuerpo más largo que la página continúa en páginas normales. La página se clasifica como role: 'part' (VDTPage.partInfo lleva el número y el título), de modo que los elementos de encabezado y pie pueden dirigirse a ella o saltársela con pages: 'part' / pages: 'body'; el backend PDF añade la parte al esquema por encima de sus capítulos. Un cuerpo vacío (:::part{…} seguido directamente de :::) es el caso habitual y también produce la página — dos partes consecutivas nunca comparten una. Un :::part anidado dentro de otra parte se aplana en la exterior.

Un tercer atributo, palette="<id>=<hex>[, <id>=<hex>…]", da a la parte sus propios colores: en la página de parte y en todas las que la siguen — hasta la siguiente parte — cada color de diseño (cabecera, pie, banda de apertura, diseños de parte y de su verso) vinculado a uno de esos ids de paleta toma el valor de la parte en lugar del de la paleta del documento. Así recolorean las secciones de un libro la pestaña de la esquina, el punto de la cabecera y la banda de apertura de capítulo sin un segundo diseño: :::part{number="II" title="…" palette="band=#f6c297"}. El flujo de texto también lo sigue: en esas mismas páginas, todo color del flujo igual al valor base de una entrada de paleta sustituida —títulos, colores de negrita, cursiva y referencias, viñetas y números de lista, etiquetas de pie, texto y filetes de tablas— toma el valor de la parte, de modo que un headings.levels[1].color vinculado a band compone los títulos de cada sección en su propio color. Las muestras de color en línea conservan el color escrito en ellas. Los pares se separan con comas, puntos y comas o espacios, = o : une id y color, y la # es opcional. Una parte sigue vigente después de cerrarse su valla: {partTitle}, {partNumber} y la paleta acompañan al flujo hasta los capítulos posteriores y — mediante continuation.part, que devuelve continuationAfter() — hasta los capítulos compuestos por separado, de modo que el segundo capítulo de una sección muestra la sección en sus cabeceras exactamente igual que el primero.

const resolved = resolvePartsConfig(config.parts, resolvedPage, resolvedBodyText, resolvedUnorderedLists, resolvedOrderedLists);
// => márgenes rellenados desde la página, bodyStyle desde el cuerpo / las listas
 
const minimal = stripPartsDefaults(config.parts);
// => undefined cuando solo quedan valores estáticos por defecto

#Estilos de encabezado

La propiedad headingStyles declara estilos con nombre que un documento aplica a un encabezado con # Título {style="<id>"}. Un estilo hace dos cosas. Sobrescribe la tipografía y el diseño del nivel del encabezado — cualquier campo de una entrada de nivel salvo level y numberingTemplate (fuente, cuerpo, color, breakBefore, span, advancedDesign, textTransform…) — y gobierna la sección que el encabezado abre: sus páginas, hasta el siguiente encabezado del mismo nivel o superior, toman las cabeceras, la geometría de página, la tipografía de cuerpo y la paleta del estilo. Así los preliminares de un libro (un prólogo a una sola columna ancha, con folios en romanos y bandas azules) conviven en un manual a dos columnas y numeración decimal sin una segunda configuración.

const config: PostextConfig = {
  headingStyles: [
    {
      id: 'preliminar',
      numbered: false,
      breakBefore: { enabled: true, parity: 'odd' },
      span: 'page',
      advancedDesign: { enabled: true, minHeight: { value: 52, unit: 'mm' }, slot: { elements: [/* bandas, `{titleText}` */] } },
      header: { elements: [/* folio | filete | `{title}. {subtitle}` */] },
      margins: { left: { value: 50, unit: 'mm' }, right: { value: 17, unit: 'mm' } },
      layout: { layoutType: 'single' },
      bodyStyle: { fontSize: { value: 10.5, unit: 'pt' }, textAlign: 'justify' },
      palette: { band: '#547396' },
    },
  ],
};
# Prólogo {style="preliminar"}
PropiedadTipoPor defectoDescripción
idstringIdentificador al que se refiere en una línea de encabezado. Un id desconocido deja el encabezado tal cual.
namestringidNombre legible (solo para la interfaz del editor).
numberedbooleantrueSi el encabezado cuenta: avanza el contador de su nivel (los números de numberingTemplate, el de la numeración de recursos), el ordinal de capítulo que hay tras y el número que imprime el índice. false para un prólogo, una lista de autores, un índice: el primer capítulo numerado que los sigue sigue siendo el capítulo 1, y queda vacío en sus páginas.
tocbooleantrueSi :::toc lista el encabezado. Un encabezado lo sobrescribe con / .
campos de nivelcomo en headings.levels[]los valores del nivelfontFamily, fontSize, lineHeight, fontWeight, italic, color, marginTop, marginBottom, breakBefore, span, advancedDesign, textTransform: cada uno que se fije sustituye al valor del nivel para los encabezados de este estilo.
header, footerDesignSlotlos del documentoCabeceras y pies de las páginas de la sección, en lugar de header / footer (los filtros parity y pages de los elementos siguen aplicándose). Una ranura vacía los elimina.
marginsPageMarginsmárgenes de páginaÁrea de cuerpo de las páginas de la sección; cada lado hereda el margen de página si no se fija, mirror incluido. Surte efecto en las páginas que la sección abre — combínalo con breakBefore.
layoutLayoutConfiglayoutDisposición de columnas de las páginas de la sección (layoutType, gutterWidth…): una sola columna ancha para un prólogo en un libro a dos columnas.
bodyStylePartsBodyStyleConfighereda bodyTextTipografía de los párrafos, citas y listas de la sección — los mismos campos que parts.bodyStyle.
paletteRecord<string, string>Sustituciones de paleta (id → hex) aplicadas a toda ranura de diseño compuesta en las páginas de la sección, sobre las de la parte en curso — el mismo mecanismo que el atributo palette de una parte.

Una sección se cierra en el siguiente encabezado del mismo nivel o superior: un # sin estilo tras uno con estilo vuelve a las cabeceras y la geometría del documento; uno con estilo abre su propia sección. Las páginas que la sección deja en blanco por paridad le pertenecen, como ocurre con los títulos de capítulo.

const resolved = resolveHeadingStylesConfig(config.headingStyles, resolvedPage, resolvedBodyText, resolvedUnorderedLists, resolvedOrderedLists);
// => sobrescrituras de nivel normalizadas, márgenes tomados de la página, bodyStyle del cuerpo
 
const minimal = stripHeadingStylesDefaults(config.headingStyles);
// => undefined cuando no queda ningún estilo

#Índice de contenidos

La propiedad toc configura lo que imprime una directiva :::toc (ver Formato del documento). El índice se ensambla a partir del esquema del documento — cada encabezado con su número y su etiqueta de página, cada :::part — de modo que sigue a los capítulos: renombra uno, muévelo a otra parte, cambia sus autores, y las entradas cambian con él. Una entrada es el número del encabezado en una columna propia, el título, una línea de puntos y la etiqueta de página en el borde derecho, y después una línea de subtítulo opcional; una parte es una fila diseñada por parts.design.

const config: PostextConfig = {
  toc: {
    levels: [{ level: 1, fontWeight: 700, color: { hex: '#00507b', model: 'hex' }, numberWidth: { value: 7.4, unit: 'mm' } }],
    unnumbered: { color: { hex: '#000000', model: 'hex' } },
    pageNumber: { fontWeight: 400, width: { value: 8, unit: 'mm' } },
    leader: { char: '.', gap: { value: 1, unit: 'mm' } },
    subtitle: { enabled: true, attr: 'author', italic: true, fontSize: { value: 8.5, unit: 'pt' } },
    parts: {
      height: { value: 23, unit: 'pt' },
      marginTop: { value: 11.5, unit: 'pt' },
      design: { elements: [/* una caja de banda, 'SECCIÓN {number}', '{titleText}', '{pageNumber}' */] },
    },
  },
};
PropiedadTipoPor defectoDescripción
levelsTocLevelConfig[]nivel 1Niveles de encabezado listados, cada uno con la tipografía de sus entradas: fontFamily, fontSize, lineHeight (por defecto la interlínea del cuerpo, para que el índice caiga en la rejilla), fontWeight, italic, color, indent (de toda la entrada), numberWidth / numberGap (la columna de números tras la que empieza el título; los números se alinean a la derecha en ella), numberFontFamily, numberFontSize, numberFontWeight, numberColor, marginTop, marginBottom. Los campos sin fijar heredan el texto de cuerpo.
unnumberedTocEntryStyleConfigSobrescrituras para los encabezados cuyo estilo declara numbered: false (un prólogo): no imprimen número y empiezan a ras en el indent del nivel.
pageNumberobjetofuente del nivel 1, peso del cuerpofontFamily, fontSize, fontWeight, italic, color de la etiqueta de página, y width (por defecto 2em): la columna que se le reserva en el borde derecho, donde se alinea a la derecha.
leaderobjetochar se repite a lo largo del hueco entre el título y el número de página, alineado a la derecha para que los puntos de entradas consecutivas queden en línea ('. ' los espacia); gap es el hueco mínimo entre el título y la línea de puntos. Un título que no dejara sitio a la etiqueta salta de línea un poco antes.
subtitleobjetoUna segunda línea bajo la entrada tomada de un atributo del encabezado (attr) — los autores del capítulo — con sus propios fontFamily, fontSize, fontWeight, italic (por defecto true), color e indent adicional. La línea comparte la interlínea de la entrada y nunca se separa de su título.
parts.enabledbooleantrueSi los separadores de parte reciben una fila.
parts.breakBeforebooleanfalseAbre una página nueva antes de cada fila de parte salvo la primera, de modo que los capítulos de cada parte se listan en una página propia.
parts.designDesignSlotvacíoDiseño de la fila; su contenedor es la fila (anchura de columna × height). Marcadores: , , …, y (la etiqueta de la página de parte). Los colores ligados a la paleta toman la palette de la propia parte, así que la fila de cada sección sale en su color. Vacío, se componen y el número de página con la tipografía de las entradas de nivel 1.
parts.height, marginTop, marginBottomDimensiondos líneas de cuerpo, 0, 0Altura de la fila y espacio a su alrededor.

Las etiquetas de página son las que el documento imprime. buildDocument() vuelve a componer un documento con :::toc con las etiquetas de la pasada anterior hasta que se estabilizan (tres pasadas adicionales como máximo); un anfitrión que compone un libro capítulo a capítulo suministra en su lugar el esquema del libro entero como PostextContent.outline, ensamblado con contentOutline() (encabezados y partes a partir del texto) y outlineFromDoc() (las mismas entradas con las etiquetas de página de una composición), y vuelve a componer el capítulo del índice cada vez que cambia el outlineKey() de ese esquema.

#Unidades y colores

#Dimensiones

Todas las medidas físicas en Postext usan el tipo Dimension — un valor emparejado con una unidad:

interface Dimension {
  value: number;
  unit: DimensionUnit; // 'cm' | 'mm' | 'in' | 'pt' | 'px' | 'em' | 'rem'
}

Unidades absolutascm, mm, in, pt, px — se convierten a píxeles usando los DPI configurados. A 300 DPI, 1 cm equivale a aproximadamente 118 px.

Unidades relativasem, rem — escalan con el tamaño de fuente actual. Un em es relativo al tamaño de fuente del propio elemento; rem es relativo al tamaño de fuente del texto de cuerpo.

#Colores

Los colores se almacenan con una representación hexadecimal y un modelo de color objetivo:

interface ColorValue {
  hex: string;        // '#ff0000', 'transparent', etc.
  model: ColorModel;  // 'hex' | 'rgb' | 'cmyk' | 'hsl'
}

El campo model indica el espacio de color previsto. Para renderizado web, 'hex' o 'rgb' son los habituales. Para flujos de trabajo de impresión, 'cmyk' preserva la intención de que el color debe especificarse en CMYK al exportar a PDF.

Como Postext apunta a salida de calidad editorial, el color por defecto del cuerpo de texto se publica con model: 'cmyk' (#000000). Los colores de encabezados, negritas, cursivas y listas toman por defecto el Color principal enlazado a la paleta (#295AA3, model: 'hex'). El fondo de página y los indicadores de interfaz (rejilla base, marcas de corte, indicadores de depuración) usan model: 'hex' por defecto. Sobrescribe color.model en cualquier campo si necesitas otra semántica de exportación.

#Fuentes personalizadas

Postext resuelve cada fontFamily contra ambos el catálogo de Google Fonts y la lista customFonts del documento. Las fuentes personalizadas tienen prioridad en caso de coincidencia de nombre: si declaras customFonts: [{ name: 'Roboto', … }], Postext usará tu archivo en lugar de la "Roboto" de Google Fonts.

Usa fuentes personalizadas cuando:

  • El documento requiere una tipografía de marca o con licencia que no está en Google Fonts.
  • El entorno no puede alcanzar la CDN de Google Fonts (sin conexión, intranet, sensibilidad de privacidad).
  • Necesitas mantener el archivo de fuente privado y evitar subirlo a un tercero.

#Esquema de configuración

type CustomFontFormat = 'woff2' | 'woff' | 'ttf' | 'otf';
type CustomFontStyle = 'normal' | 'italic';
 
interface CustomFontVariant {
  weight: number;           // CSS font-weight, 100..900
  style: CustomFontStyle;
  fileId: string;           // id opaco del binario en almacenamiento externo
  format: CustomFontFormat;
  fileName?: string;        // nombre original del archivo (se muestra en la UI)
}
 
interface CustomFontFamily {
  name: string;             // se usa donde encajaría un nombre de Google Font
  variants: CustomFontVariant[];
}
 
interface PostextConfig {
  // ...
  customFonts?: CustomFontFamily[];
}

El binario de cada variante no se empotra en la propia configuración. La configuración solo guarda punteros fileId; los bytes viven fuera de ella. En el sandbox eso significa IndexedDB (almacén clave-valor, exclusivo del navegador, privado del documento). Un integrador que empotre Postext en otro entorno es libre de resolver fileId como prefiera —un endpoint de servidor, un service worker, lo que sea— mientras los bytes lleguen al hilo principal antes de buildDocument.

#Gestionar fuentes personalizadas en el sandbox

Abre el panel Fonts desde la barra de actividad izquierda (entre Resources y Configuration). Para cada familia:

  1. Añadir familia — crea una familia vacía; renómbrala en línea.
  2. Subir variante(s) — elige un peso (100–900) y un estilo (normal / italic), y selecciona uno o varios archivos .woff2, .woff, .ttf u .otf. Cada archivo se convierte en su propia variante asociada a la combinación (peso, estilo) seleccionada; el nombre del archivo queda guardado y se muestra en la fila para diferenciar variantes. Puedes retocar el peso o el estilo de una variante desde sus desplegables en cualquier momento.
  3. Se permiten variantes duplicadas. Si dos archivos caen en la misma ranura (peso, estilo), se guardan los dos y aparece un aviso Duplicate font variant para que ajustes los extras.
  4. Eliminar variante o Eliminar familia — elimina la entrada de la configuración y los bytes guardados en IndexedDB.

Una vez declarada la familia, cada selector de fuente la agrupa bajo Custom, por encima de la lista de Google Fonts. Al elegirla, queda cableada a todos los campos fontFamily donde la apliques.

#Comportamiento de renderizado

Bajo el capó:

  • Cuando customFonts cambia, cada familia declarada se registra automáticamente como FontFace en document.fonts — por lo que el visor HTML, el visor Canvas (que mide a través de document.fonts) y cualquier referencia CSS directa toman la cara personalizada sin necesidad de abrir antes el Font Picker.
  • El worker de composición recibe los mismos ArrayBuffer por el mismo canal de transferencia de font payloads, así la medición (buildFontString, pretext) produce métricas idénticas a las de Google Fonts.
  • Cambiar o eliminar una variante descarta la cara cacheada en el worker para esa familia y se vuelve a registrar en el siguiente build, de modo que las previsualizaciones se mantienen sincronizadas con el conjunto actual de variantes.
  • Exportación a PDF: los binarios subidos fluyen por la misma canalización PdfFontProvider. Los .woff2 se descomprimen; los .ttf y .otf se pasan tal cual. .woff se rechaza con un mensaje claro (pdf-lib no puede empotrar WOFF crudo — vuelve a subirlo como .woff2/.ttf/.otf). El OpenType con tablas CFF (.otf cuya firma es OTTO) se empotra sin subsetting, porque el subsetter CFF de pdf-lib recorre cada glifo al hacer save() y puede bloquearse durante minutos con fuentes reales; saltarse el subset intercambia algo más de tamaño de PDF por tiempos de render consistentes.

#Avisos de fuentes faltantes

El panel de avisos reconoce tres modos nuevos de fallo específicos de las fuentes personalizadas (todos gobernados por el mismo toggle debug.warnings.missingFont que ya controla el aviso genérico "no cargada"):

  • Familia desconocida — un fontFamily referencia un nombre que no es ni una Google Font conocida ni una familia personalizada actualmente declarada. También salta al instante cuando eliminas una familia personalizada que algún fontFamily todavía referencia, sin esperar a que el DOM lo note.
  • Variante faltante — la familia existe pero al menos una de las ranuras estándar (400 / 700, normal / italic) no tiene archivo subido. El aviso enumera las combinaciones concretas que faltan.
  • Variante duplicada — dos o más archivos comparten la misma ranura (peso, estilo) dentro de una misma familia. Solo uno se usa al renderizar; el aviso te recuerda que retoques las entradas sobrantes.

Al pulsar cualquiera de los avisos se abre el panel Fonts para que subas la variante necesaria, vuelvas a añadir la familia o desambigües los duplicados.

#Paleta de colores

La propiedad colorPalette de PostextConfig permite definir un conjunto reutilizable de colores con nombre y referenciarlos desde cualquier ColorValue de la configuración. Es el equivalente en Postext a las custom properties de CSS o al panel de muestras de InDesign: cambia la entrada una sola vez y todos los colores que apunten a ella se actualizan en el documento.

interface ColorPaletteEntry {
  id: string;       // identificador estable — referenciado por ColorValue.paletteId
  name: string;     // etiqueta legible que se muestra en las UIs del sandbox
  value: ColorValue;
}

#La paleta por defecto

Postext incluye una paleta por defecto con una única entrada llamada Color principal (id: 'main-color', hex #295AA3). Varios valores por defecto — color de encabezados, color de negritas/cursivas del cuerpo, color de viñetas y marcadores numéricos de listas — referencian esa entrada vía paletteId: 'main-color', de modo que al cambiar esa única muestra se retintea cada elemento del documento que la utilice.

Se puede inspeccionar, clonar o comparar la paleta por defecto mediante tres exportaciones:

import {
  DEFAULT_COLOR_PALETTE,
  cloneDefaultColorPalette,
  isDefaultColorPalette,
} from 'postext';
 
// Snapshot de solo lectura de la paleta publicada.
DEFAULT_COLOR_PALETTE;
// => [{ id: 'main-color', name: 'Main Color', value: { hex: '#295AA3', model: 'hex' } }]
 
// Copia independiente — muta esta, no DEFAULT_COLOR_PALETTE.
const palette = cloneDefaultColorPalette();
 
// Comprueba si el usuario ha personalizado la paleta.
isDefaultColorPalette(palette); // true

La paleta vive en el nivel superior de la configuración:

const config: PostextConfig = {
  colorPalette: [
    { id: 'tinta',  name: 'Tinta',  value: { hex: '#0a0a0a', model: 'cmyk' } },
    { id: 'acento', name: 'Acento', value: { hex: '#b8860b', model: 'hex' } },
  ],
  bodyText: { color: { hex: '#000000', model: 'cmyk', paletteId: 'tinta' } },
  headings: { color: { hex: '#000000', model: 'hex', paletteId: 'acento' } },
};

#Referenciar una entrada de la paleta

Cualquier ColorValue de la configuración — fondo de página, color del cuerpo de texto, colores de encabezados, filetes de columna, colores de listas, taskCompletedColor, color de las marcas de corte, color de la rejilla base, indicadores de depuración — puede llevar un campo opcional paletteId que apunta a una entrada de colorPalette. Cuando está presente, el hex / model de la entrada de la paleta ganan al hex / model almacenados como respaldo. El respaldo en línea solo se usa si la paleta falta, está vacía o no contiene ese id — útil al exportar una configuración que leerá una herramienta que no entienda paletas.

#Cómo se aplican las paletas

buildDocument ejecuta la paleta en dos puntos para que los colores referenciados funcionen tanto para las sobrescrituras que hayas indicado como para los valores por defecto que se completen después:

  1. applyPaletteToConfig(config) — aplana cada ColorValue del config original que lleve paletteId. Útil para inspeccionar qué verá realmente el motor.
  2. applyPaletteToResolvedConfig(resolved, palette) — se ejecuta después de resolver los valores por defecto y reescribe los defaults enlazados a la paleta (color de encabezados, de negrita/cursiva del cuerpo, de listas) para que coincidan con la paleta activa.

Raramente necesitas invocarlas, pero ambas están exportadas para inspección y reutilización:

import {
  applyPaletteToConfig,
  applyPaletteToResolvedConfig,
  resolveColorValue,
} from 'postext';
 
const flat = applyPaletteToConfig(config);
// Cada ColorValue con paletteId en el config original queda sustituido por
// el hex/model de la entrada de la paleta.
 
// `applyPaletteToResolvedConfig` normalmente lo gestiona buildDocument; úsalo
// directamente si construyes un ResolvedConfig a mano y quieres aplicar la paleta.

resolveColorValue(value, palette, fallback) es la variante para un único valor, útil cuando compones configuraciones de forma imperativa y necesitas resolver un color suelto.

#Editar la paleta

Para eliminar una entrada conviene usar unlinkPaletteRefs (exportado desde postext-sandbox) de modo que cualquier ColorValue que todavía apunte al id eliminado se reescriba con el hex / model de respaldo. La sección "Paleta de colores" del sandbox lo hace automáticamente.

#Visor HTML

La propiedad htmlViewer controla cómo el backend HTML dispone las páginas en pantalla. Solo se aplica cuando renderizas con renderToHtml / renderToHtmlIndexed; los caminos de canvas y PDF la ignoran por completo — consumen directamente los page.width, page.height y page.dpi configurados.

interface HtmlViewerConfig {
  maxCharsPerLine?: number;     // Ancho objetivo de columna, en caracteres de la fuente del cuerpo.
  columnGap?: number;            // Espacio horizontal entre columnas en modo multi-columna (px).
  optimalLineBreaking?: boolean; // Usar Knuth–Plass dentro del visor HTML en lugar de greedy.
  overrides?: HtmlViewerOverrides; // Configuración parcial solo para pantalla, fusionada sobre la del documento.
}
 
type HtmlViewerOverrides = Omit<PostextConfig, 'htmlViewer'>;
PropiedadTipoPor defectoDescripción
maxCharsPerLinenumber70Medida objetivo de cada columna renderizada, expresada en caracteres de la fuente del cuerpo. El viewport mide una cadena representativa de prosa con esa longitud para obtener el ancho real en píxeles — así el resultado se adapta a cualquier combinación de fuente proporcional y tamaño.
columnGapnumber50Espacio horizontal, en píxeles CSS, entre columnas cuando el visor está en modo multi-columna. Se ignora en modo columna única.
optimalLineBreakingbooleanfalseActiva la división de líneas Knuth–Plass dentro del visor HTML. Está desactivado por defecto porque el visor recompone la maquetación en cada resize o cambio de tamaño — el algoritmo greedy first-fit es lo bastante rápido para sentirse instantáneo. Actívalo cuando quieras los mismos cortes óptimos que usa el backend canvas.
overridesHtmlViewerOverridesUna configuración parcial del documento que solo se aplica en pantalla. El visor HTML la fusiona sobre la configuración del documento antes de componer (applyHtmlViewerOverrides); canvas y PDF la ignoran. Los objetos se fusionan recursivamente; una lista levels (encabezados, listas, índice) se fusiona entrada a entrada por level; cualquier otra lista — los elements de un bloque de diseño, calloutStyles, colorPalette… — sustituye a la lista base por completo. Uso típico: un inicio de capítulo sin las bandas de imprenta, o una página de parte cuyo título se envuelve contra el número en lugar de un ancho fijo de caja de corte. El sandbox lo edita como JSON.
const config: PostextConfig = {
  headings: { levels: [{ level: 1, span: 'page', breakBefore: { enabled: true } }] },
  htmlViewer: {
    // En pantalla, los capítulos siguen de corrido sin el inicio a toda página.
    overrides: { headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false } }] } },
  },
};

El resolver y el stripper siguen el mismo patrón que las demás secciones:

import {
  DEFAULT_HTML_VIEWER_CONFIG,
  resolveHtmlViewerConfig,
  stripHtmlViewerDefaults,
} from 'postext';
 
const resolved = resolveHtmlViewerConfig(config.htmlViewer);
// => { maxCharsPerLine: 70, columnGap: 50, optimalLineBreaking: false }
 
const minimal = stripHtmlViewerDefaults(config.htmlViewer);
// => undefined cuando todo coincide con los valores por defecto

Consulta Integrar el visor HTML más abajo para un ejemplo completo.

#Generación de PDF (configuración)

La propiedad pdfGeneration controla cómo el backend PDF emite el documento final. Estos ajustes los consume el paquete postext-pdf en el momento de exportar; los visores canvas y HTML los ignoran.

type PdfColorSpace = 'rgb' | 'cmyk' | 'grayscale';
 
interface PdfGenerationConfig {
  outlines?: boolean;          // Emitir marcadores PDF desde el árbol de encabezados.
  forceColorSpace?: boolean;   // Convertir todos los colores a `colorSpace`.
  colorSpace?: PdfColorSpace;  // Espacio destino cuando `forceColorSpace` es true.
  accessible?: boolean;        // Salida etiquetada orientada a PDF/UA (árbol de estructura, texto alternativo, idioma).
}
PropiedadTipoPor defectoDescripción
outlinesbooleantrueEmite outlines (marcadores) PDF a partir de la jerarquía de encabezados, de modo que los lectores puedan saltar directamente a cualquier encabezado desde la barra lateral del visor PDF. Desactívalo para documentos en los que el árbol de encabezados no aporta valor (por ejemplo, pósteres de una sola página).
forceColorSpacebooleanfalseCuando es true, todos los colores del PDF renderizado se convierten a colorSpace al exportar. Déjalo desactivado en PDFs pensados para pantalla si los colores de entrada ya están en el espacio deseado; actívalo para garantizar un único espacio de color partiendo de fuentes heterogéneas.
colorSpace'rgb' | 'cmyk' | 'grayscale''cmyk'Espacio de color destino usado cuando forceColorSpace está activado. Usa 'cmyk' para imprenta offset, 'rgb' para PDFs solo-pantalla y 'grayscale' para pruebas en blanco y negro. No tiene efecto si forceColorSpace es false.
accessiblebooleantrueGenera un PDF accesible y etiquetado, orientado a PDF/UA-1: un árbol de estructura lógica en orden de lectura (títulos que nunca saltan de nivel, párrafos, listas, citas, callouts, tablas con celdas de cabecera, figuras con su texto alternativo y su pie, fórmulas, referencias como enlaces), el título y el idioma del documento (el locale de primer nivel), la identificación PDF/UA en los metadatos XMP, y toda marca decorativa (fondo de página, filetes, rejilla base, cabeceras y pies corridos, marcas de corte, cabeceras de tabla repetidas) señalada como artefacto para que los lectores de pantalla la omitan. Una figura sin altText usa su pie y, si no lo tiene, su etiqueta. Desactívalo solo para masters de imprenta donde la estructura adicional sobre.
pdfGeneration: {
  outlines: true,
  accessible: true,
  forceColorSpace: true,
  colorSpace: 'cmyk',
}

El resolver y el stripper siguen el mismo patrón que las demás secciones:

import {
  DEFAULT_PDF_GENERATION_CONFIG,
  resolvePdfGenerationConfig,
  stripPdfGenerationDefaults,
} from 'postext';
 
const resolved = resolvePdfGenerationConfig(config.pdfGeneration);
// => { outlines: true, forceColorSpace: false, colorSpace: 'cmyk', accessible: true }
 
const minimal  = stripPdfGenerationDefaults(config.pdfGeneration);
// => undefined cuando todo coincide con los valores por defecto

Consulta Generación de PDF más abajo para la receta completa de exportación.

#Depuración

La propiedad debug agrupa dos tipos de ayudas de autoría: superposiciones visuales que mantienen sincronizados el texto fuente y la composición renderizada, y un conjunto de avisos que muestran en el panel de avisos del editor los problemas tipográficos o estructurales del documento. Ninguno de los dos afecta a la salida exportada.

PropiedadTipoDescripción
cursorSyncSyncIndicatorConfigCursor reflejado en la composición renderizada — ver Superposiciones visuales.
selectionSyncSyncIndicatorConfigSelección de la fuente resaltada en la página — ver Superposiciones visuales.
looseLineHighlightLooseLineHighlightConfigSuperposición sobre las líneas justificadas flojas — ver Superposiciones visuales.
pageNegativeNegativo de alto contraste de la página — ver Superposiciones visuales.
warningsWarningsToggleConfigUn booleano por cada clase de aviso de autoría que muestra el editor — ver Avisos.

#Superposiciones visuales

PropiedadTipoPor defectoDescripción
cursorSync.enabledbooleantrueMuestra un cursor en la composición renderizada que refleja la posición del cursor en la fuente.
cursorSync.colorColorValue#2563ebColor de ese cursor.
selectionSync.enabledbooleantrueResalta el rango renderizado que coincide con la selección en la fuente.
selectionSync.colorColorValue#fde04780Color del resaltado — un amarillo translúcido por defecto.
looseLineHighlight.enabledbooleanfalsePinta una superposición sobre las líneas justificadas cuyo espaciado entre palabras supera threshold veces el ancho del espacio normal.
looseLineHighlight.colorColorValue#ff000040Color de esa superposición.
looseLineHighlight.thresholdnumber3Multiplicador del ancho del espacio normal a partir del cual una línea justificada cuenta como floja. El aviso looseLines usa el mismo umbral.
pageNegative.enabledbooleanfalseRenderiza una superposición en negativo de alto contraste sobre la página — útil para auditar visualmente la forma general de una doble página (densidad de texto, equilibrio de columnas, espacio en blanco) de un vistazo, sin distraerse con el detalle de los glifos.

Cada SyncIndicatorConfig es { enabled: boolean; color?: ColorValue }. LooseLineHighlightConfig es { enabled: boolean; color?: ColorValue; threshold?: number }. pageNegative es un simple interruptor { enabled: boolean }.

debug: {
  cursorSync: { enabled: true, color: { hex: '#ff0066', model: 'hex' } },
  selectionSync: { enabled: false, color: { hex: '#fde04780', model: 'hex' } },
  looseLineHighlight: { enabled: true, color: { hex: '#ff000040', model: 'hex' }, threshold: 3 },
  pageNegative: { enabled: true },
}

#Avisos

debug.warnings controla qué problemas de autoría aparecen en el panel de avisos del editor. Cada clave es un interruptor booleano independiente; pon una en false para silenciar ese aviso concreto sin desactivar los demás.

interface WarningsToggleConfig {
  missingFont?: boolean;
  looseLines?: boolean;
  headingHierarchy?: boolean;
  consecutiveHeadings?: boolean;
  listAfterHeading?: boolean;
  designIssues?: boolean;
}
PropiedadTipoPor defectoDescripción
missingFontbooleantrueAvisa cuando una fuente referenciada por la configuración no ha podido cargarse en el navegador. Detecta erratas en fontFamily y paquetes @fontsource/... ausentes antes de que se conviertan en sustituciones silenciosas por una fuente de reserva en la salida renderizada.
looseLinesbooleantrueAvisa de las líneas justificadas cuyo espaciado entre palabras supera debug.looseLineHighlight.threshold. Complementa la superposición: el aviso las enumera en el panel, la superposición las muestra en su posición.
headingHierarchybooleantrueAvisa de niveles de encabezado que saltan un rango — por ejemplo, un H1 seguido directamente de un H3. Los saltos en la jerarquía suelen indicar o bien una errata en la profundidad del encabezado o un malentendido sobre el esquema del documento.
consecutiveHeadingsbooleanfalseAvisa cuando un encabezado va seguido inmediatamente de otro encabezado, sin párrafo ni lista intermedios. Desactivado por defecto porque los encabezados encadenados son legítimos en muchas plantillas (título + subtítulo, capítulo + epígrafe); actívalo en manuscritos donde cada encabezado debe introducir prosa.
listAfterHeadingbooleanfalseAvisa cuando una lista empieza inmediatamente después de un encabezado, sin párrafo introductorio. Desactivado por defecto porque el material de referencia suele hacerlo; actívalo en escritura narrativa donde cada lista debería estar encuadrada por prosa.
designIssuesbooleantrueAvisa de problemas de integridad en las ranuras de diseño — encabezados de página, pies de página y ranuras de diseño avanzado de los encabezados. Cubre cadenas de anclaje cíclicas y referencias de anclaje colgantes (un elemento anclado a un #id que ya no existe), un encabezado con span de página cuyo breakBefore está desactivado, y un diseño avanzado habilitado cuyos elementos nunca renderizan .
debug: {
  warnings: {
    missingFont: true,
    looseLines: true,
    headingHierarchy: true,
    consecutiveHeadings: true,
    listAfterHeading: false,
    designIssues: true,
  },
}

#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() de postext/worker, no llamando a buildDocument directamente 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 a buildDocument directamente, 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 a buildDocument en 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`);

#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.

Postext · renderizar una página como imagen
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.

#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 defecto

Tambié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.

#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.

#Caché de medidas

La medición del texto es el paso costoso del proceso de composición. Para evitar volver a medir el mismo bloque entre iteraciones del bucle de convergencia — o entre recomposiciones cuando solo ha cambiado la configuración — Postext incluye una caché de medidas conectable:

import {
  createMeasurementCache,
  cachedMeasureBlock,
  cachedMeasureRichBlock,
  clearMeasurementCache,
} from 'postext';
import type { MeasurementCache } from 'postext';
 
const cache: MeasurementCache = createMeasurementCache();
 
// Misma firma que measureBlock / measureRichBlock, más un argumento de caché.
const measured = cachedMeasureBlock(cache, block, options);
const richMeasured = cachedMeasureRichBlock(cache, richBlock, options);
 
// Vaciar todas las entradas almacenadas (p. ej. al cambiar la familia tipográfica):
clearMeasurementCache(cache);

buildDocument mantiene su propia caché internamente a lo largo del bucle de convergencia, así que para el uso habitual no necesitas tocar nada de esto. Estas funciones se exponen para aplicaciones que orquestan el pipeline pieza a pieza — por ejemplo un editor que reejecuta la composición en cada pulsación de tecla y quiere reutilizar las medidas del fotograma anterior.

#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:

  1. Crea un worker una vez por viewport con createLayoutWorker().
  2. Registra las fuentes una vez por familia enviando ArrayBuffers transferibles vía registerFonts(payloads).
  3. Compone con build(content, config, { signal }), pasando un AbortSignal fresco en cada llamada para poder cancelar builds obsoletos.
  4. Supersede cualquier build anterior abortando su signal antes de iniciar el siguiente — este es el patrón last-wins.
  5. 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 VDTDocument terminado.
  • Cancelación last-wins. build(content, config, { signal }) inyecta un AbortSignal en el worker. Abortar antes de que termine produce un AbortError en el lado principal; dentro del worker el pipeline lanza un BuildCancelledError en el siguiente punto de comprobación por bloque y se detiene de inmediato.
  • Caché de medición por worker. El worker mantiene un único MeasurementCache durante 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 mediante new FontFace(...) en el propio FontFaceSet del 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 MathRender a 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 mediante opts.worker) y devuelve un handle tipado.
  • 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 con AbortError.
  • FontPayload{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }. El buffer se transfiere al worker cuando llamas a registerFonts.
  • BuildCancelledError (re-exportado desde postext) — lo que lanza buildDocument internamente cuando options.shouldCancel devuelve true. Normalmente no lo ves en el hilo principal: el protocolo del worker lo convierte en un AbortError antes 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' }).

#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.

#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:

  1. Consulta https://api.fontsource.org/v1/fonts/{id-de-familia} para descubrir los pesos disponibles y si la familia incluye un eje variable.
  2. Construye una URL CSS2 de Google Fonts que cubre todos los pesos y estilos que declara la familia.
  3. Descarga la hoja de estilos @font-face generada, extrae cada declaración src: url(...) format('woff2') y descarga los bytes en crudo.
  4. Devuelve un FontPayload[] donde buffer es un ArrayBuffer fresco por llamada — importante, porque registerFonts transfiere 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.

#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 FontFaceSet del 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 buildDocument directamente 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 un VDTDocument.
  • renderToHtmlIndexed(doc, options) — convierte el VDT en una única cadena HTML más un desglose por página y por bloque. El desglose permite parchear el DOM de forma barata cuando solo han cambiado algunos bloques entre renders.
  • resolveHtmlViewerConfig(partial) — completa los valores por defecto del visor HTML (maxCharsPerLine, columnGap, optimalLineBreaking).
  • buildFontString + measureGlyphWidth + dimensionToPx — primitivas de medida usadas para derivar un ancho de columna real en píxeles a partir de un objetivo en caracteres.
  • createMeasurementCache / clearMeasurementCache — cachés enchufables para reutilizar medidas entre recomposiciones.

#Ejemplo en vivo: una cadena HTML

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

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

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

#Integración mínima

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

import { useEffect, useRef } from 'react';
import {
  buildDocument,
  renderToHtmlIndexed,
  resolveHtmlViewerConfig,
  buildFontString,
  measureGlyphWidth,
  dimensionToPx,
  createMeasurementCache,
} 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 onFontsDone = () => relayout();
    document.fonts?.addEventListener?.('loadingdone', onFontsDone);
 
    return () => {
      ro.disconnect();
      document.fonts?.removeEventListener?.('loadingdone', onFontsDone);
    };
  }, [markdown, config, mode]);
 
  return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}

Algunas notas sobre lo que hace el ejemplo:

  • Se mide la columna, no se aproxima. Como maxCharsPerLine es un objetivo expresado en caracteres, el ancho real en píxeles depende de la fuente del cuerpo. measureGlyphWidth da una medida real sobre la fuente elegida, manteniendo la medida consistente al cambiar de fuente.
  • Se reescribe la página. El visor HTML trata cada "página" del VDT como una columna en pantalla. El ejemplo sobrescribe page.width con el ancho medido de columna, pone los márgenes a cero (el padding vive fuera de la página, en el .pt-doc envolvente) y usa HTML_DPI = 144 para que 8pt de cuerpo resuelva a 16px.
  • Atento a la carga de fuentes. document.fonts.loadingdone se dispara cuando llega una web font recién solicitada. Sin recomponer entonces, 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 incrementalrenderToHtmlIndexed devuelve pages[i].blocks, cada uno con un id estable y el HTML externo del bloque. Cuando solo unos cuantos bloques difieren entre dos renders puedes reemplazar esos envoltorios en el sitio en lugar de reconstruir innerHTML.
  • Superposiciones — apila un SVG absoluto sobre cada .pt-page para cursores, selecciones o la rejilla base.

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.

#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. renderToPdf en 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 a renderToPdf directamente.

#Instalación

npm install postext postext-pdf

#API pública

El paquete expone un único punto de entrada y un puñado de tipos:

  • renderToPdf(doc, options): Promise<Uint8Array> — toma un VDTDocument y devuelve los bytes crudos del PDF.
  • PdfFontProvider — la firma de callback (family, weight, style) => Promise<Uint8Array> que renderToPdf usa para pedir los bytes de una fuente cuando necesita incrustar una combinación family/weight/style nueva.
  • RenderToPdfOptions{ fontProvider: PdfFontProvider, pageNegative?: boolean }.
  • decompressWoff2(bytes): Uint8Array — helper que convierte un archivo WOFF2 en bytes TTF, que es el formato que pdf-lib puede incrustar directamente.

#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 el VDT buscando cada fontString que aparece (una por cada combinación family|weight|style, incluidas las variantes bold, italic y bold-italic) e invoca tu proveedor una sola vez por combinación única. El proveedor devuelve un Uint8Array con bytes TTF u OTF; pdf-lib los subsetea y los incrusta.

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.

#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 de weight: 600 sobre 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 bytesCache a nivel de módulo, no por llamada) para que regenerar el PDF tras un cambio de configuración sea prácticamente gratis.

#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);
};

#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.

Postext · generar un PDF en el navegador
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.

#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. Ver Marcas de corte.
  • page.dpi: 300 (o superior) — los puntos PDF están fijos a 72/pulgada, pero la aritmética de maquetación de Postext corre en píxeles; un DPI mayor da una subdivisión más fina para elementos medidos en mm o cm.
  • colors.model: 'cmyk' — preserva la intención de que los colores se definieron en espacio CMYK. El fallback hex sigue siendo el que se usa para dibujar realmente al PDF hoy; model se documenta aquí porque viaja junto al VDT para herramientas aguas abajo.
  • { pageNegative: true } en RenderToPdfOptions — 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).

#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í. openBundle lo 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 traducidos al idioma del paquete, después la config del manifiesto y después los ajustes propios del idioma. 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.
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.

#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. 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). bundleImageUrl(bundle) es el resolvedor resourceImageUrl de renderToHtml. Las dos recolorean las figuras SVG cuando diagramStyle.singleInk está activo.
  • 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 (:::toc) recibe el esquema del libro entero. Admite las mismas opciones que buildDocument, más config para sustituir la configuración del paquete y cache para compartir una caché de medidas.
  • 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.
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.

#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.

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.

#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' },
  headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}' }] },
};
 
// 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 (Exportar en su fila del panel de Proyectos). 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 (Proyectos → Nuevo → Importar .postext…), corrige el markdown, la configuración o los recursos con la vista previa en vivo, el panel de avisos 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): 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.
  • 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 y cómo se miden los tamaños intrínsecos.
  • 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, resolveBundleLocale), svgSize / bitmapSize y los tipos del formato (BundleManifest, BundleResourceSpec, BundleFontFamilySpec, …).

El Sandbox está construido sobre estas piezas. Añade sus propios identificadores de almacenamiento y los registros de páginas de layouts.json.