Saltar al contenido principal

Configuración de Postext

Actualizado: 2026-04-21|18 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 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' } },
  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. Solo se usa cuando sizePreset es 'custom'.
heightDimension24 cmAlto de página. Solo se usa cuando sizePreset es 'custom'.
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.
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.

#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.11×17Bolsillo12×19Rústica17×24Técnico21×28~A4
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.

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.

#Disposición

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.der.inf.izq.
Cada lado de la página puede tener su propio margen.

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

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'.
columnRuleColumnRuleConfigdesactivadoFilete vertical opcional trazado entre columnas. Ver más abajo.

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

#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. Cada elemento se posiciona de forma independiente: marginFromBody es la distancia absoluta entre el borde del elemento que mira al cuerpo y el borde del cuerpo. Los elementos no se empujan entre sí — dos elementos con el mismo marginFromBody se solaparán.

Cada bloque contiene una lista de elementos de texto y línea (rule).

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[][]Lista 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.

PropiedadTipoPor defectoDescripción
kind'text'Discriminador.
contentstring''Plantilla. Admite los marcadores listados más abajo.
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).
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.
marginFromBodyDimension6 ptDistancia absoluta entre el borde del elemento que mira al cuerpo y el borde del cuerpo. Independiente de otros elementos.
marginFromEdgeDimension0 ptDesplazamiento horizontal respecto al borde al que está alineado. Solo aplica cuando align es 'left' o 'right'.

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.

#Elementos de línea

Los elementos de línea dibujan una línea horizontal dentro del bloque.

PropiedadTipoPor defectoDescripción
kind'rule'Discriminador.
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.

#Texto de cuerpo

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 pxsm8 pxmd16 pxlg24 pxxl40 px2xl64 px
Los pasos de espaciado construyen un ritmo predecible en la maquetación.

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

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 (#517538)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 (#517538)Color aplicado a los fragmentos en cursiva. Mismo enlace a la paleta que boldColor.
textAlign'left' | 'justify''justify'Alineación del texto. El texto justificado distribuye el espaciado a lo largo de cada línea para bordes uniformes.
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).
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.

#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). Al nivel 1000 domina sobre alternativas que supondrían un estiramiento de espacio de hasta aproximadamente r≈2,15. 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.
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.

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 (#517538)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.

#Configuración por nivel

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

NivelTamaño de fuente por defecto
H118 pt
H215 pt
H312 pt
H410 pt
H59 pt
H68 pt

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.
numberingTemplatestring''Reservado para numeración automática de encabezados (por ejemplo, 'Capítulo . '). Actualmente se almacena en la configuración resuelta y se propaga a través del pipeline; el renderizado lo respetará en una próxima versión.
headings: {
  fontFamily: 'Merriweather',
  levels: [
    { level: 1, fontSize: { value: 24, unit: 'pt' }, color: { hex: '#1a1a2e', model: 'hex' } },
    { level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true },
  ]
}

#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 (#517538)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 (#517538)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 ')'.
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 y verticalOffset — se aplica el mismo encadenamiento de sangrías que en las listas no ordenadas.

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 KaTeX, 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: '#517538', 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.

#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 (#517538, 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 #517538). 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: '#517538', 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.
}
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.

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.
}
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.
pdfGeneration: {
  outlines: 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' }
 
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.

#Superposiciones visuales

PropiedadTipoPor defectoDescripción
cursorSyncSyncIndicatorConfigactivado, #2563ebMuestra un cursor en la composición renderizada que refleja la posición del cursor en la fuente.
selectionSyncSyncIndicatorConfigactivado, #fde04780Resalta el rango renderizado que coincide con la selección en la fuente.
looseLineHighlightLooseLineHighlightConfigdesactivado, #ff000040, 3Pinta una superposición sobre las líneas justificadas cuyo espaciado entre palabras supera el multiplicador indicado del ancho del espacio normal.
pageNegativedesactivadoRenderiza 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;
}
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.
debug: {
  warnings: {
    missingFont: true,
    looseLines: true,
    headingHierarchy: true,
    consecutiveHeadings: true,
    listAfterHeading: false,
  },
}

#Configuración avanzada

Las siguientes secciones de configuración están definidas en el sistema de tipos y serán soportadas por el motor de composición a medida que evolucione. Se incluyen aquí como referencia.

#Configuración de columnas

Control granular de columnas: número de columnas explícito, tamaño del medianil, filetes de columna (separadores visuales) y equilibrado de columnas.

#Colocación de recursos

Controla cómo se posicionan imágenes, tablas, figuras y citas destacadas: 'topOfColumn', 'inline', 'floatLeft', 'floatRight', 'fullWidthBreak' o 'margin'. Opciones para colocación diferida y preservación de la relación de aspecto.

#Tipografía

Controles tipográficos avanzados: mínimos de líneas para huérfanas/viudas, optimización de bordes irregulares, espaciado alrededor de encabezados y figuras, y reglas de agrupación para encabezados con párrafos y figuras con pies de foto.

#Referencias

Colocación de notas al pie ('columnBottom', 'pageBottom', 'endOfSection'), estilo de marcador ('number', 'symbol', 'custom'), numeración automática de figuras/tablas y notas al margen.

#Configuraciones por sección

Aplica diferentes ajustes de columnas, tipografía o colocación de recursos a secciones específicas del documento, identificadas mediante selectores tipo CSS.

#Renderizador

Formato de salida objetivo: 'web' para renderizado HTML/canvas, 'pdf' para salida de impresión.

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

#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: 9, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }

Resolvers disponibles: resolvePageConfig, resolveLayoutConfig, resolveBodyTextConfig, resolveHeadingsConfig, resolveUnorderedListsConfig, resolveOrderedListsConfig, resolveDebugConfig, resolveHtmlViewerConfig. Las paletas de color se aplican por separado con applyPaletteToConfig(config), applyPaletteToResolvedConfig(resolved, palette) y resolveColorValue(value, palette, fallback) — ver Paleta de colores.

resolveUnorderedListsConfig y resolveOrderedListsConfig son los únicos resolvers que aceptan un segundo argumento — el ResolvedBodyTextConfig ya resuelto — porque las listas heredan fontFamily y color del cuerpo de texto:

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, PAGE_SIZE_PRESETS, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, DEFAULT_HEADINGS_CONFIG, DEFAULT_UNORDERED_LISTS_STATIC, DEFAULT_ORDERED_LISTS_STATIC, DEFAULT_DEBUG_CONFIG, DEFAULT_HTML_VIEWER_CONFIG, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX.

#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: stripPageDefaults, stripLayoutDefaults, stripBodyTextDefaults, stripHeadingsDefaults, stripUnorderedListsDefaults, stripOrderedListsDefaults, stripDebugDefaults, stripHtmlViewerDefaults.

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

#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.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' } },
  },
);
 
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 (PdfViewport.tsx:51).

#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 sangre y marcas de corte alrededor del trim. Ver Líneas 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 autoraron 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).